├── tftp └── .gitkeep ├── admin ├── .dockerignore ├── .gitignore ├── webui ├── public │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── robots.txt │ ├── manifest.json │ └── index.html ├── src │ ├── setupTests.js │ ├── index.js │ ├── App.test.js │ ├── index.css │ ├── App.css │ ├── models │ │ ├── ipxeaccount.js │ │ ├── bootentry.js │ │ └── computers.js │ └── App.js ├── .gitignore ├── package.json ├── .eslintcache └── README.md ├── docs ├── images │ ├── account-list.png │ ├── bootentry-edit.png │ ├── bootentry-list.png │ ├── computer-edit.png │ └── computer-list.png ├── swagger.yaml ├── swagger.json └── docs.go ├── models ├── error.go ├── tag.go ├── bootorder.go ├── token.go ├── bootentry.go ├── ipxeaccount.go ├── bootentryfile.go └── computer.go ├── templates ├── grub_empty.gohtml ├── empty.gohtml ├── grub_index.gohtml └── index.gohtml ├── utils ├── tokencleaner.go ├── helpers │ ├── random.go │ └── detectType.go ├── filestore.go ├── config.go ├── database.go └── templatefunctions.go ├── midlewares ├── devwebui.go └── basicauth.go ├── go.mod ├── docker-compose.yml ├── Dockerfile ├── .github └── workflows │ └── build.yml ├── controllers ├── tftp.go ├── generic.go ├── grub.go ├── ipxeaccount.go ├── computer.go ├── bootentry.go └── ipxescript.go ├── README.md ├── main.go └── LICENSE /tftp/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /admin: -------------------------------------------------------------------------------- 1 | webui/build -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | webui/node_modules 2 | webui/build 3 | ipxeblue -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ipxeblue 2 | vendors 3 | .podman-data 4 | !tftp/.gitkeep 5 | tftp/ 6 | -------------------------------------------------------------------------------- /webui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarnaud/ipxeblue/HEAD/webui/public/favicon.ico -------------------------------------------------------------------------------- /webui/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarnaud/ipxeblue/HEAD/webui/public/logo192.png -------------------------------------------------------------------------------- /webui/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarnaud/ipxeblue/HEAD/webui/public/logo512.png -------------------------------------------------------------------------------- /webui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /docs/images/account-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarnaud/ipxeblue/HEAD/docs/images/account-list.png -------------------------------------------------------------------------------- /models/error.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type Error struct { 4 | Error string `json:"message"` 5 | } 6 | -------------------------------------------------------------------------------- /docs/images/bootentry-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarnaud/ipxeblue/HEAD/docs/images/bootentry-edit.png -------------------------------------------------------------------------------- /docs/images/bootentry-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarnaud/ipxeblue/HEAD/docs/images/bootentry-list.png -------------------------------------------------------------------------------- /docs/images/computer-edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarnaud/ipxeblue/HEAD/docs/images/computer-edit.png -------------------------------------------------------------------------------- /docs/images/computer-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarnaud/ipxeblue/HEAD/docs/images/computer-list.png -------------------------------------------------------------------------------- /templates/grub_empty.gohtml: -------------------------------------------------------------------------------- 1 | echo "INFO: empty bootorder on this computer, boot on next device" 2 | sleep 1 3 | exit 0 -------------------------------------------------------------------------------- /templates/empty.gohtml: -------------------------------------------------------------------------------- 1 | #!ipxe 2 | echo INFO: empty bootorder on this computer, boot on next device 3 | sleep 1 4 | exit 0 -------------------------------------------------------------------------------- /webui/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /webui/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /models/tag.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/google/uuid" 4 | 5 | type Tag struct { 6 | Key string `gorm:"primaryKey;index" json:"key"` 7 | Value string `json:"value"` 8 | ComputerUUID uuid.UUID `gorm:"primaryKey;type:uuid;index" json:"-"` 9 | } 10 | -------------------------------------------------------------------------------- /webui/src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /webui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /webui/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /webui/src/App.css: -------------------------------------------------------------------------------- 1 | .ra-input-tags .ra-input { 2 | display: inline-block; 3 | } 4 | 5 | .ra-input-files .ra-input { 6 | display: table-cell; 7 | vertical-align: middle; 8 | } 9 | 10 | .ra-input-files .ra-input:last-child { 11 | padding-left: 40px; 12 | } 13 | 14 | .ra-input-files .ra-input:last-child .MuiFormHelperText-root{ 15 | display: none; 16 | } 17 | 18 | .ra-input-files .ra-input:last-child div div{ 19 | display: inline-block; 20 | } -------------------------------------------------------------------------------- /utils/tokencleaner.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/aarnaud/ipxeblue/models" 5 | "github.com/rs/zerolog/log" 6 | "gorm.io/gorm" 7 | "time" 8 | ) 9 | 10 | func TokenCleaner(db *gorm.DB) { 11 | token := models.Token{} 12 | for { 13 | result := db.Where("expire_at < NOW()").Delete(&token) 14 | if result.RowsAffected > 0 { 15 | log.Info().Msgf("%d tokens was expired and deleted", result.RowsAffected) 16 | } 17 | time.Sleep(time.Second) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /utils/helpers/random.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_." 9 | 10 | var seededRand *rand.Rand = rand.New( 11 | rand.NewSource(time.Now().UnixNano())) 12 | 13 | func StringWithCharset(length int, charset string) string { 14 | b := make([]byte, length) 15 | for i := range b { 16 | b[i] = charset[seededRand.Intn(len(charset))] 17 | } 18 | return string(b) 19 | } 20 | 21 | func RandomString(length int) string { 22 | return StringWithCharset(length, charset) 23 | } 24 | -------------------------------------------------------------------------------- /webui/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "iPXEblue", 3 | "name": "iPXEblue UI", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /utils/helpers/detectType.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "strconv" 6 | ) 7 | 8 | const TYPE_INT = "int" 9 | const TYPE_FlOAT = "float" 10 | const TYPE_BOOL = "bool" 11 | const TYPE_STRING = "string" 12 | const TYPE_UUID = "uuid" 13 | 14 | func StringToType(value string) string { 15 | _, err := strconv.ParseInt(value, 10, 64) 16 | if err == nil { 17 | return TYPE_INT 18 | } 19 | _, err = strconv.ParseFloat(value, 64) 20 | if err == nil { 21 | return TYPE_FlOAT 22 | } 23 | _, err = strconv.ParseBool(value) 24 | if err == nil { 25 | return TYPE_BOOL 26 | } 27 | _, err = uuid.Parse(value) 28 | if err == nil { 29 | return TYPE_UUID 30 | } 31 | return TYPE_STRING 32 | } 33 | -------------------------------------------------------------------------------- /models/bootorder.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "time" 6 | ) 7 | 8 | type Bootorder struct { 9 | Order int `gorm:"not null;" json:"-"` 10 | ComputerUuid uuid.UUID `gorm:"primaryKey;not null;"` 11 | Computer *Computer `gorm:"foreignkey:computer_uuid;References:Uuid;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` 12 | BootentryUuid uuid.UUID `gorm:"primaryKey;not null;"` 13 | Bootentry *Bootentry `gorm:"foreignkey:bootentry_uuid;References:Uuid;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` 14 | CreatedAt time.Time `gorm:"autoCreateTime;not null;default:current_timestamp" json:"-"` 15 | UpdatedAt time.Time `gorm:"autoUpdateTime;not null;default:current_timestamp" json:"-"` 16 | } 17 | -------------------------------------------------------------------------------- /midlewares/devwebui.go: -------------------------------------------------------------------------------- 1 | package midlewares 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "net/http" 6 | "net/http/httputil" 7 | "strings" 8 | ) 9 | 10 | func MidlewareDevWebUI() gin.HandlerFunc { 11 | return func(c *gin.Context) { 12 | if strings.HasPrefix(c.Request.URL.Path, "/api/") { 13 | c.Next() 14 | return 15 | } 16 | if c.Request.URL.Path == "/admin" || strings.HasPrefix(c.Request.URL.Path, "/admin/") || c.Request.URL.Path == "/sockjs-node" { 17 | ProxyDevWebUI(c) 18 | } 19 | 20 | if c.Request.Header.Get("Upgrade") == "websocket" { 21 | ProxyDevWebUI(c) 22 | return 23 | } 24 | c.Next() 25 | } 26 | } 27 | 28 | func ProxyDevWebUI(c *gin.Context) { 29 | director := func(req *http.Request) { 30 | req.URL = c.Request.URL 31 | req.URL.Scheme = "http" 32 | req.URL.Host = "localhost:3000" 33 | } 34 | proxy := &httputil.ReverseProxy{Director: director} 35 | proxy.ServeHTTP(c.Writer, c.Request) 36 | } 37 | -------------------------------------------------------------------------------- /models/token.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "time" 6 | ) 7 | 8 | type Token struct { 9 | Token string `gorm:"primaryKey" json:"token"` 10 | Computer Computer `gorm:"ForeignKey:ComputerUUID;References:Uuid;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` 11 | ComputerUUID uuid.UUID `gorm:"not null" json:"computer_uuid"` 12 | Bootentry Bootentry `gorm:"ForeignKey:BootentryUUID;References:Uuid,Name;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` 13 | BootentryUUID uuid.UUID `gorm:"not null" json:"bootentry_uuid"` 14 | // BootentryFile can be null if we generate DownloadBaseURL 15 | BootentryFile *BootentryFile `gorm:"ForeignKey:BootentryUUID,Filename;References:BootentryUUID,Name;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` 16 | Filename *string `json:"filename"` 17 | ExpireAt time.Time `gorm:"index:idx_expire_at" json:"expire"` 18 | } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aarnaud/ipxeblue 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 7 | github.com/gin-contrib/cors v1.3.1 8 | github.com/gin-contrib/logger v0.0.2 9 | github.com/gin-gonic/gin v1.6.3 10 | github.com/go-openapi/spec v0.20.0 // indirect 11 | github.com/google/uuid v1.1.2 12 | github.com/jackc/pgtype v1.5.0 13 | github.com/minio/minio-go/v7 v7.0.6 14 | github.com/pin/tftp/v3 v3.0.0 15 | github.com/pkg/errors v0.9.1 // indirect 16 | github.com/rs/zerolog v1.16.0 17 | github.com/spf13/viper v1.7.1 18 | github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 19 | github.com/swaggo/gin-swagger v1.3.0 20 | github.com/swaggo/swag v1.7.0 21 | golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 22 | golang.org/x/net v0.0.0-20201216054612-986b41b23924 // indirect 23 | golang.org/x/tools v0.0.0-20201223010750-3fa0e8f87c1a // indirect 24 | gorm.io/driver/postgres v1.0.5 25 | gorm.io/gorm v1.21.7 26 | ) 27 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | database: 4 | image: docker.io/postgres 5 | environment: 6 | - POSTGRES_USER=ipxeblue 7 | - POSTGRES_PASSWORD=thisisnotapassword 8 | - POSTGRES_DB=ipxeblue 9 | volumes: 10 | - ./.podman-data/postgres:/var/lib/postgresql/data 11 | ports: 12 | - 5432:5432 13 | 14 | minio: 15 | image: docker.io/minio/minio 16 | volumes: 17 | - ./.podman-data/minio:/data 18 | ports: 19 | - 9000:9000 20 | environment: 21 | MINIO_ACCESS_KEY: minio 22 | MINIO_SECRET_KEY: minio123 23 | command: server /data 24 | healthcheck: 25 | test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] 26 | interval: 30s 27 | timeout: 20s 28 | retries: 3 29 | 30 | webui: 31 | image: docker.io/node:lts-buster 32 | working_dir: /webui 33 | volumes: 34 | - ./webui:/webui 35 | command: "yarn start" 36 | ports: 37 | - 3000:3000 38 | 39 | #volumes: 40 | # db-data: 41 | # driver: local 42 | # data: 43 | # driver: local -------------------------------------------------------------------------------- /webui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ipxeblue-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "/admin/", 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^5.11.4", 8 | "@testing-library/react": "^11.1.0", 9 | "@testing-library/user-event": "^12.1.10", 10 | "prop-types": "^15.7.2", 11 | "ra-data-json-server": "^3.11.1", 12 | "react": "^17.0.1", 13 | "react-admin": "^3.11.1", 14 | "react-dom": "^17.0.1", 15 | "react-scripts": "4.0.1", 16 | "web-vitals": "^0.2.4" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject" 23 | }, 24 | "eslintConfig": { 25 | "extends": [ 26 | "react-app", 27 | "react-app/jest" 28 | ] 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ############################ 2 | # STEP 1 build executable binary 3 | ############################ 4 | FROM golang as builder 5 | 6 | 7 | WORKDIR $GOPATH/src/aarnaud/ipxeblue/ 8 | COPY . . 9 | 10 | RUN go mod vendor 11 | RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /go/bin/ipxeblue -mod vendor main.go 12 | 13 | ############################ 14 | # STEP 2 build webui 15 | ############################ 16 | FROM node:16-bullseye as builderui 17 | 18 | 19 | WORKDIR /webui/ 20 | COPY ./webui . 21 | 22 | RUN yarn install 23 | RUN yarn build 24 | 25 | ############################ 26 | # STEP 3 ca-certificates 27 | ############################ 28 | FROM alpine:3 as alpine 29 | 30 | RUN apk add -U --no-cache ca-certificates 31 | 32 | 33 | ############################ 34 | # STEP 4 build a small image 35 | ############################ 36 | FROM scratch 37 | 38 | ENV GIN_MODE=release 39 | WORKDIR /app/ 40 | # Import from builder. 41 | COPY --from=alpine /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 42 | COPY --from=builder /go/bin/ipxeblue /app/ipxeblue 43 | COPY --from=builderui /webui/build /app/admin 44 | COPY templates /app/templates 45 | ENTRYPOINT ["/app/ipxeblue"] 46 | EXPOSE 8080 -------------------------------------------------------------------------------- /webui/.eslintcache: -------------------------------------------------------------------------------- 1 | [{"/webui/src/index.js":"1","/webui/src/App.js":"2","/webui/src/models/computers.js":"3","/webui/src/models/ipxeaccount.js":"4","/webui/src/models/bootentry.js":"5"},{"size":219,"mtime":1609957302467,"results":"6","hashOfConfig":"7"},{"size":3151,"mtime":1616005052196,"results":"8","hashOfConfig":"9"},{"size":4258,"mtime":1684558334905,"results":"10","hashOfConfig":"9"},{"size":2405,"mtime":1623124607631,"results":"11","hashOfConfig":"9"},{"size":3398,"mtime":1684559659877,"results":"12","hashOfConfig":"9"},{"filePath":"13","messages":"14","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"79ytzx",{"filePath":"15","messages":"16","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"asp27a",{"filePath":"17","messages":"18","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"19","messages":"20","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"21","messages":"22","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/webui/src/index.js",[],"/webui/src/App.js",[],"/webui/src/models/computers.js",[],"/webui/src/models/ipxeaccount.js",[],"/webui/src/models/bootentry.js",[]] -------------------------------------------------------------------------------- /templates/grub_index.gohtml: -------------------------------------------------------------------------------- 1 | echo 2 | echo 3 | echo .###.########.##.....#.#######.########.##......##.....#.######## 4 | echo ..##.##.....#..##...##.##......##.....#.##......##.....#.##...... 5 | echo ..##.##.....#...##.##..##......##.....#.##......##.....#.##...... 6 | echo ..##.########....###...######..########.##......##.....#.######.. 7 | echo ..##.##.........##.##..##......##.....#.##......##.....#.##...... 8 | echo ..##.##........##...##.##......##.....#.##......##.....#.##...... 9 | echo .###.##.......##.....#.#######.########.#######..#######.######## 10 | echo 11 | echo 12 | 13 | echo "loading ipxe from {{ .BaseURL }}" 14 | sleep 2 15 | 16 | smbios --type 1 --get-string 4 --set smbios_manufacturer 17 | smbios --type 1 --get-string 5 --set smbios_product 18 | smbios --type 1 --get-string 7 --set smbios_serial 19 | smbios --type 1 --get-uuid 8 --set smbios_uuid 20 | smbios --type 2 --get-string 8 --set smbios_asset 21 | insmod http 22 | 23 | echo "(tftp)/grub/$net_default_mac/$net_default_ip/$smbios_uuid/-$smbios_asset/-$smbios_manufacturer/-$smbios_serial/-$smbios_product/$grub_cpu/$grub_platform/grub.cfg" 24 | source "(tftp)/grub/$net_default_mac/$net_default_ip/$smbios_uuid/-$smbios_asset/-$smbios_manufacturer/-$smbios_serial/-$smbios_product/$grub_cpu/$grub_platform/grub.cfg" -------------------------------------------------------------------------------- /templates/index.gohtml: -------------------------------------------------------------------------------- 1 | #!ipxe 2 | 3 | echo 4 | echo 5 | echo .###.########.##.....#.#######.########.##......##.....#.######## 6 | echo ..##.##.....#..##...##.##......##.....#.##......##.....#.##...... 7 | echo ..##.##.....#...##.##..##......##.....#.##......##.....#.##...... 8 | echo ..##.########....###...######..########.##......##.....#.######.. 9 | echo ..##.##.........##.##..##......##.....#.##......##.....#.##...... 10 | echo ..##.##........##...##.##......##.....#.##......##.....#.##...... 11 | echo .###.##.......##.....#.#######.########.#######..#######.######## 12 | echo 13 | echo 14 | 15 | echo loading ipxe from {{ .BaseURL }} 16 | sleep 2 17 | 18 | isset ${username} || goto menu 19 | isset ${password} || goto menu 20 | 21 | :bootipxe 22 | chain --replace {{ .Scheme }}://${username:uristring}:${password:uristring}@{{ .Host }}/?asset=${asset}&buildarch=${buildarch}&hostname=${hostname}&mac=${mac:hexhyp}&ip=${ip}&manufacturer=${manufacturer}&platform=${platform}&product=${product}&serial=${serial}&uuid=${uuid}&version=${version} || goto failed 23 | 24 | :menu 25 | menu Username or Password not set 26 | item login Enter Username/Password 27 | item exit Exit to continue boot on computer disk 28 | choose --default exit --timeout 5000 target && goto ${target} 29 | 30 | :login 31 | login 32 | goto bootipxe 33 | 34 | :failed 35 | echo Boot failed, waiting 5 sec 36 | sleep 5 37 | exit 1 38 | 39 | :exit 40 | exit 1 -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: docker image publish 2 | 3 | on: 4 | push: 5 | # Publish `master` as Docker `latest` image. 6 | branches: 7 | - master 8 | - develop 9 | 10 | # Publish `v1.2.3` tags as releases. 11 | tags: 12 | - v* 13 | 14 | # Run tests for any PRs. 15 | pull_request: 16 | 17 | env: 18 | IMAGE_NAME: ipxeblue 19 | 20 | jobs: 21 | # Push image to GitHub Packages. 22 | # See also https://docs.docker.com/docker-hub/builds/ 23 | push: 24 | runs-on: ubuntu-latest 25 | permissions: 26 | packages: write 27 | contents: read 28 | 29 | steps: 30 | - uses: actions/checkout@v3 31 | 32 | - name: Build image 33 | run: docker build . --file Dockerfile --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}" 34 | 35 | - name: Log in to registry 36 | # This is where you will update the PAT to GITHUB_TOKEN 37 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin 38 | 39 | - name: Push image 40 | run: | 41 | IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME 42 | 43 | # Change all uppercase to lowercase 44 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') 45 | # Strip git ref prefix from version 46 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 47 | # Strip "v" prefix from tag name 48 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 49 | # Use Docker `latest` tag convention 50 | [ "$VERSION" == "master" ] && VERSION=latest 51 | echo IMAGE_ID=$IMAGE_ID 52 | echo VERSION=$VERSION 53 | docker tag $IMAGE_NAME $IMAGE_ID:$VERSION 54 | docker push $IMAGE_ID:$VERSION 55 | -------------------------------------------------------------------------------- /webui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | iPXEblue 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /models/bootentry.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/aarnaud/ipxeblue/utils/helpers" 7 | "github.com/google/uuid" 8 | "time" 9 | ) 10 | 11 | type Bootentry struct { 12 | Uuid uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` 13 | Name string `gorm:"uniqueIndex:idx_name" json:"name"` 14 | Description string `json:"description"` 15 | Persistent *bool `gorm:"not null;default:FALSE" json:"persistent"` 16 | IpxeScript string `json:"ipxe_script"` 17 | GrupScript string `json:"grub_script"` 18 | Files []BootentryFile `gorm:"foreignkey:bootentry_uuid;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"files"` 19 | Bootorder []*Bootorder `gorm:"foreignKey:bootentry_uuid;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` 20 | CreatedAt time.Time `json:"created_at"` 21 | UpdatedAt time.Time `json:"updated_at"` 22 | } 23 | 24 | func (b *Bootentry) GetFile(filename string) *BootentryFile { 25 | for _, file := range b.Files { 26 | if file.Name == filename { 27 | file.Bootentry = b 28 | return &file 29 | } 30 | } 31 | return nil 32 | } 33 | 34 | func (b *Bootentry) GetDownloadBasePath() (string, *Token) { 35 | token := Token{ 36 | Token: helpers.RandomString(15), 37 | Bootentry: *b, 38 | BootentryFile: nil, 39 | // TODO: expose token duration in configuration 40 | ExpireAt: time.Now().Add(time.Minute * 10), 41 | } 42 | return fmt.Sprintf("/files/token/%s/%s/", token.Token, b.Uuid), &token 43 | } 44 | 45 | func (o *Bootentry) UnmarshalJSON(data []byte) error { 46 | type Alias Bootentry 47 | aux := &struct { 48 | *Alias 49 | }{ 50 | Alias: (*Alias)(o), 51 | } 52 | if err := json.Unmarshal(data, &aux); err != nil { 53 | return err 54 | } 55 | falseRef := false 56 | if o.Persistent == nil { 57 | o.Persistent = &falseRef 58 | } 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /models/ipxeaccount.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "golang.org/x/crypto/bcrypt" 7 | "time" 8 | ) 9 | 10 | type Ipxeaccount struct { 11 | Username string `gorm:"primarykey" json:"username"` 12 | Password string `json:"password,omitempty"` 13 | IsAdmin *bool `gorm:"not null;default:FALSE" json:"is_admin"` 14 | LastLogin time.Time `json:"last_login"` 15 | CreatedAt time.Time `json:"created_at"` 16 | UpdatedAt time.Time `json:"updated_at"` 17 | } 18 | 19 | var IpxeAccountSearchFields = []string{ 20 | "username", 21 | } 22 | 23 | // HashPassword : Encrypt user password 24 | func HashPassword(password string) (string, error) { 25 | bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 26 | return string(bytes), err 27 | } 28 | 29 | // MarshalJSON initializes nil slices and then marshals the bag to JSON 30 | func (o Ipxeaccount) MarshalJSON() ([]byte, error) { 31 | type Alias Ipxeaccount 32 | alias := (Alias)(o) 33 | // empty password 34 | alias.Password = "" 35 | // Add Id for react-admin 36 | return json.Marshal(&struct { 37 | Id string `json:"id"` 38 | Alias 39 | }{ 40 | Id: alias.Username, 41 | Alias: alias, 42 | }) 43 | 44 | } 45 | 46 | func (c *Ipxeaccount) UnmarshalJSON(data []byte) error { 47 | type Alias Ipxeaccount 48 | aux := &struct { 49 | PasswordConfirmation string `json:"password_confirmation"` 50 | *Alias 51 | }{ 52 | Alias: (*Alias)(c), 53 | } 54 | if err := json.Unmarshal(data, &aux); err != nil { 55 | return err 56 | } 57 | if aux.Password != "" && aux.Password != aux.PasswordConfirmation { 58 | return fmt.Errorf("Password missmatch") 59 | } 60 | if aux.Password != "" { 61 | hash, err := HashPassword(aux.Password) 62 | if err != nil { 63 | return err 64 | } 65 | aux.Password = hash 66 | } 67 | falseRef := false 68 | if c.IsAdmin == nil { 69 | c.IsAdmin = &falseRef 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /utils/filestore.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "github.com/minio/minio-go/v7" 6 | "github.com/minio/minio-go/v7/pkg/credentials" 7 | "github.com/rs/zerolog/log" 8 | ) 9 | 10 | func NewFileStore(config *Config) *minio.Client { 11 | ctx := context.Background() 12 | // Initialize minio client object. 13 | minioClient, err := minio.New(config.MinioConfig.Endpoint, &minio.Options{ 14 | Creds: credentials.NewStaticV4(config.MinioConfig.AccessKey, config.MinioConfig.SecretKey, ""), 15 | Secure: config.MinioConfig.Secure, 16 | }) 17 | if err != nil { 18 | log.Fatal().Err(err) 19 | } 20 | 21 | exist, err := minioClient.BucketExists(ctx, config.MinioConfig.BucketName) 22 | if err != nil { 23 | log.Fatal().Err(err) 24 | } 25 | 26 | if !exist { 27 | log.Info().Msgf("creating bukcet %s \n", config.MinioConfig.BucketName) 28 | err := minioClient.MakeBucket(ctx, config.MinioConfig.BucketName, minio.MakeBucketOptions{}) 29 | if err != nil { 30 | log.Fatal().Err(err) 31 | } 32 | } 33 | return minioClient 34 | } 35 | 36 | func RemoveRecursive(client *minio.Client, bucketName string, prefix string) error { 37 | objectsCh := make(chan minio.ObjectInfo) 38 | 39 | // Send object names that are needed to be removed to objectsCh 40 | go func() { 41 | defer close(objectsCh) 42 | // List all objects from a bucket-name with a matching prefix. 43 | for objectInfo := range client.ListObjects(context.Background(), bucketName, minio.ListObjectsOptions{ 44 | Recursive: true, 45 | Prefix: prefix, 46 | }) { 47 | if objectInfo.Err != nil { 48 | log.Error().Err(objectInfo.Err).Msg("failed to list objects to remove") 49 | } 50 | objectsCh <- objectInfo 51 | } 52 | }() 53 | 54 | opts := minio.RemoveObjectsOptions{ 55 | GovernanceBypass: true, 56 | } 57 | 58 | for removeObjectError := range client.RemoveObjects(context.Background(), bucketName, objectsCh, opts) { 59 | return removeObjectError.Err 60 | 61 | } 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /midlewares/basicauth.go: -------------------------------------------------------------------------------- 1 | package midlewares 2 | 3 | import ( 4 | "encoding/base64" 5 | "github.com/aarnaud/ipxeblue/models" 6 | "github.com/gin-gonic/gin" 7 | "golang.org/x/crypto/bcrypt" 8 | "gorm.io/gorm" 9 | "net/http" 10 | "strconv" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | func BasicAuthIpxeAccount(onlyAdmin bool) gin.HandlerFunc { 16 | realm := "Basic realm=" + strconv.Quote("Authorization Required") 17 | return func(c *gin.Context) { 18 | if c.Request.URL.Path == "/" && len(c.Request.URL.Query()) == 0 { 19 | // No authentication require 20 | return 21 | } 22 | db := c.MustGet("db").(*gorm.DB) 23 | 24 | auth := strings.SplitN(c.GetHeader("Authorization"), " ", 2) 25 | if len(auth) != 2 || auth[0] != "Basic" { 26 | c.Header("WWW-Authenticate", realm) 27 | c.AbortWithStatus(http.StatusUnauthorized) 28 | return 29 | } 30 | 31 | payload, _ := base64.StdEncoding.DecodeString(auth[1]) 32 | pair := strings.SplitN(string(payload), ":", 2) 33 | if len(pair) != 2 { 34 | c.Header("WWW-Authenticate", realm) 35 | c.AbortWithStatus(http.StatusUnauthorized) 36 | return 37 | } 38 | 39 | account := models.Ipxeaccount{} 40 | var result *gorm.DB 41 | if onlyAdmin { 42 | result = db.First(&account, "username = ? AND is_admin = TRUE", pair[0]) 43 | } else { 44 | result = db.First(&account, "username = ?", pair[0]) 45 | } 46 | 47 | if result.RowsAffected == 0 { 48 | c.Header("WWW-Authenticate", realm) 49 | c.AbortWithStatus(http.StatusUnauthorized) 50 | return 51 | } 52 | 53 | if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(pair[1])); err != nil { 54 | c.Header("WWW-Authenticate", realm) 55 | c.AbortWithStatus(http.StatusUnauthorized) 56 | return 57 | } 58 | 59 | // update last login field if it's older than 5min 60 | if time.Now().Sub(account.LastLogin).Seconds() > 300 { 61 | account.LastLogin = time.Now() 62 | db.Model(&account).Updates(account) 63 | } 64 | c.Set("account", &account) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /utils/config.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/rs/zerolog/log" 5 | "github.com/spf13/viper" 6 | "net/url" 7 | ) 8 | 9 | type MinioConfig struct { 10 | Endpoint string 11 | AccessKey string 12 | SecretKey string 13 | BucketName string 14 | Secure bool 15 | } 16 | 17 | type Config struct { 18 | Port int 19 | EnableAPIAuth bool 20 | MinioConfig MinioConfig 21 | BaseURL *url.URL 22 | GrubSupportEnabled bool 23 | TFTPEnabled bool 24 | DefaultBootentryName string 25 | } 26 | 27 | func GetConfig() *Config { 28 | // Enable VIPER to read Environment Variables 29 | viper.AutomaticEnv() // To get the value from the config file using key// viper package read .env 30 | 31 | config := Config{ 32 | Port: 8080, 33 | EnableAPIAuth: true, 34 | MinioConfig: MinioConfig{ 35 | Endpoint: "127.0.0.1:9000", 36 | BucketName: "ipxeblue", 37 | }, 38 | GrubSupportEnabled: false, 39 | TFTPEnabled: false, 40 | } 41 | 42 | if p := viper.GetInt("PORT"); p != 0 { 43 | config.Port = p 44 | } 45 | 46 | if value := viper.GetString("MINIO_ENDPOINT"); value != "" { 47 | config.MinioConfig.Endpoint = value 48 | } 49 | 50 | config.MinioConfig.AccessKey = viper.GetString("MINIO_ACCESS_KEY") 51 | config.MinioConfig.SecretKey = viper.GetString("MINIO_SECRET_KEY") 52 | config.MinioConfig.Secure = viper.GetBool("MINIO_SECURE") 53 | 54 | if value := viper.GetString("MINIO_BUCKETNAME"); value != "" { 55 | config.MinioConfig.BucketName = value 56 | } 57 | 58 | if viper.IsSet("ENABLE_API_AUTH") { 59 | config.EnableAPIAuth = viper.GetBool("ENABLE_API_AUTH") 60 | } 61 | 62 | BaseURL := "http://127.0.0.1:8080" 63 | if value := viper.GetString("BASE_URL"); value != "" { 64 | BaseURL = value 65 | } 66 | 67 | u, err := url.Parse(BaseURL) 68 | if err != nil { 69 | log.Panic().Err(err).Msg("failed to parse BASE_URL") 70 | } 71 | config.BaseURL = u 72 | 73 | config.GrubSupportEnabled = viper.GetBool("GRUB_SUPPORT_ENABLED") 74 | config.TFTPEnabled = viper.GetBool("TFTP_ENABLED") 75 | config.DefaultBootentryName = viper.GetString("DEFAULT_BOOTENTRY_NAME") 76 | 77 | return &config 78 | } 79 | -------------------------------------------------------------------------------- /utils/database.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aarnaud/ipxeblue/models" 6 | zlog "github.com/rs/zerolog/log" 7 | "github.com/spf13/viper" 8 | "gorm.io/driver/postgres" 9 | "gorm.io/gorm" 10 | "gorm.io/gorm/logger" 11 | "log" 12 | "os" 13 | "time" 14 | ) 15 | 16 | func Database() *gorm.DB { 17 | var err error 18 | var db *gorm.DB 19 | var databaseUrl string 20 | 21 | viperDBUrl := viper.GetString("DATABASE_URL") 22 | 23 | if viperDBUrl == "" { 24 | DBuser := viper.GetString("DB_USER") 25 | DBpassword := viper.GetString("DB_PASSWORD") 26 | DBname := viper.GetString("DB_NAME") 27 | DBhost := viper.GetString("DB_HOST") 28 | DBport := viper.GetString("DB_PORT") 29 | DBsslmode := viper.GetString("DB_SSLMODE") 30 | databaseUrl = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", 31 | DBuser, DBpassword, DBhost, DBport, DBname, DBsslmode) 32 | } else { 33 | databaseUrl = viperDBUrl 34 | } 35 | 36 | dbLogger := logger.New( 37 | log.New(os.Stdout, "", log.LstdFlags), // io writer 38 | logger.Config{ 39 | SlowThreshold: 200 * time.Millisecond, // Slow SQL threshold 40 | LogLevel: logger.Warn, // Log level 41 | IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger 42 | Colorful: false, // Disable color 43 | }, 44 | ) 45 | 46 | db, err = gorm.Open(postgres.New(postgres.Config{ 47 | DSN: databaseUrl, 48 | }), &gorm.Config{ 49 | Logger: dbLogger, 50 | }) 51 | if err != nil { 52 | zlog.Panic().Err(err).Msg("failed to connect to database!") 53 | } 54 | 55 | err = db.AutoMigrate(&models.Computer{}, &models.Tag{}, &models.Ipxeaccount{}, &models.Bootentry{}, 56 | &models.BootentryFile{}, &models.Bootorder{}, &models.Token{}) 57 | if err != nil { 58 | zlog.Panic().Err(err).Msg("failed to automigrate database!") 59 | } 60 | 61 | // Custom migration database schema 62 | // remove bootentry_uuid in computer since we have bootorder 63 | computerRef := &models.Computer{} 64 | if db.Migrator().HasColumn(computerRef, "bootentry_uuid") { 65 | if err = db.Migrator().DropColumn(computerRef, "bootentry_uuid"); err != nil { 66 | zlog.Panic().Err(err).Msg("failed to drop column bootentry_uuid") 67 | } 68 | } 69 | 70 | return db 71 | } 72 | -------------------------------------------------------------------------------- /models/bootentryfile.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/google/uuid" 7 | ) 8 | 9 | type BootentryFile struct { 10 | Name string `gorm:"primaryKey;index" json:"name"` 11 | SubPath string `gorm:"default:''" json:"subpath"` 12 | Protected *bool `gorm:"not null;default:FALSE" json:"protected"` 13 | Templatized *bool `gorm:"not null;default:FALSE" json:"templatized"` 14 | BootentryUUID uuid.UUID `gorm:"type:uuid;primaryKey;index" json:"-"` 15 | Bootentry *Bootentry `gorm:"foreignkey:bootentry_uuid;References:Uuid;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 16 | } 17 | 18 | var BootentrySearchFields = []string{ 19 | "name", 20 | } 21 | 22 | func (b *BootentryFile) GetFileStorePath() string { 23 | return fmt.Sprintf("%s/files/%s", b.BootentryUUID.String(), b.Name) 24 | } 25 | 26 | func (b *BootentryFile) GetAPIDownloadPath() string { 27 | return fmt.Sprintf("/api/v1/bootentries/%s/files/%s", b.BootentryUUID.String(), b.Name) 28 | } 29 | 30 | func (b *BootentryFile) GetDownloadPath() (string, *Token) { 31 | basePath, token := b.Bootentry.GetDownloadBasePath() 32 | if *b.Protected { 33 | token.BootentryFile = b 34 | return fmt.Sprintf("%s%s", basePath, b.Name), token 35 | } 36 | return fmt.Sprintf("/files/public/%s/%s", b.BootentryUUID.String(), b.Name), nil 37 | } 38 | 39 | // MarshalJSON initializes nil slices and then marshals the bag to JSON 40 | func (o BootentryFile) MarshalJSON() ([]byte, error) { 41 | type ReactFile struct { 42 | Src string `json:"src"` 43 | Title string `json:"title"` 44 | } 45 | type Alias BootentryFile 46 | alias := (Alias)(o) 47 | 48 | // Add Id for react-admin 49 | return json.Marshal(&struct { 50 | File ReactFile `json:"file"` 51 | Alias 52 | }{ 53 | File: ReactFile{ 54 | Src: o.GetAPIDownloadPath(), 55 | Title: alias.Name, 56 | }, 57 | Alias: alias, 58 | }) 59 | } 60 | 61 | func (o *BootentryFile) UnmarshalJSON(data []byte) error { 62 | 63 | type Alias BootentryFile 64 | aux := &struct { 65 | *Alias 66 | }{ 67 | Alias: (*Alias)(o), 68 | } 69 | if err := json.Unmarshal(data, &aux); err != nil { 70 | return err 71 | } 72 | falseRef := false 73 | if o.Protected == nil { 74 | o.Protected = &falseRef 75 | } 76 | if o.Templatized == nil { 77 | o.Templatized = &falseRef 78 | } 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /webui/src/models/ipxeaccount.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | BooleanField, 4 | BooleanInput, 5 | Create, 6 | Datagrid, 7 | DateField, 8 | DateTimeInput, 9 | Edit, 10 | EditButton, 11 | Filter, 12 | List, 13 | Pagination, 14 | SimpleForm, 15 | TextField, 16 | TextInput, 17 | required, 18 | } from 'react-admin'; 19 | 20 | const PostPagination = props => ; 21 | const EditTitle = ({ record }) => { 22 | return {record ? `${record.username}` : ''}; 23 | }; 24 | 25 | const IpxeaccountFilter = (props) => ( 26 | 27 | 28 | 29 | 30 | ); 31 | 32 | export const IpxeaccountList = props => ( 33 | } perPage={15} filters={} sort={{ field: 'username', order: 'ASC' }} {...props}> 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | 45 | export const IpxeaccountCreate = props => ( 46 | // undoable={false} disable optimistic rendering 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ); 56 | 57 | export const IpxeaccountEdit = props => ( 58 | } {...props}> 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | ); -------------------------------------------------------------------------------- /utils/templatefunctions.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/aarnaud/ipxeblue/models" 7 | "github.com/gin-gonic/gin" 8 | "github.com/google/uuid" 9 | "gorm.io/gorm" 10 | "text/template" 11 | ) 12 | 13 | func GetCustomFunctions(c *gin.Context, tpl *template.Template) template.FuncMap { 14 | config := c.MustGet("config").(*Config) 15 | db := c.MustGet("db").(*gorm.DB) 16 | baseURL := *config.BaseURL 17 | 18 | // use the same scheme from request to generate URL 19 | if schem := c.Request.Header.Get("X-Forwarded-Proto"); schem != "" { 20 | baseURL.Scheme = schem 21 | } 22 | return map[string]interface{}{ 23 | "BootentryTemplate": func(name uuid.UUID, data interface{}) (ret string, err error) { 24 | buf := bytes.NewBuffer([]byte{}) 25 | err = tpl.ExecuteTemplate(buf, name.String(), data) 26 | ret = buf.String() 27 | return 28 | }, 29 | "GetBaseURL": func() (ret string, err error) { 30 | return baseURL.String(), nil 31 | }, 32 | "GetDownloadURL": func(bootentry models.Bootentry, filename string) (ret string, err error) { 33 | file := bootentry.GetFile(filename) 34 | if file == nil { 35 | return fmt.Sprintf("%s not found in bootentry %s", filename, bootentry.Uuid), err 36 | } 37 | path, token := file.GetDownloadPath() 38 | if token != nil { 39 | // Get computer in gin context to add it in token, to used it in file template. 40 | token.Computer = *c.MustGet("computer").(*models.Computer) 41 | db.Create(&token) 42 | } 43 | return fmt.Sprintf("%s%s", baseURL.String(), path), err 44 | }, 45 | "GetGrubDownloadPath": func(bootentry models.Bootentry, filename string) (ret string, err error) { 46 | file := bootentry.GetFile(filename) 47 | if file == nil { 48 | return fmt.Sprintf("%s not found in bootentry %s", filename, bootentry.Uuid), err 49 | } 50 | path, token := file.GetDownloadPath() 51 | if token != nil { 52 | // Get computer in gin context to add it in token, to used it in file template. 53 | token.Computer = *c.MustGet("computer").(*models.Computer) 54 | db.Create(&token) 55 | } 56 | return fmt.Sprintf("(http,%s)%s", baseURL.Host, path), err 57 | }, 58 | "GetDownloadBaseURL": func(bootentry models.Bootentry) (ret string, err error) { 59 | path, token := bootentry.GetDownloadBasePath() 60 | if token != nil { 61 | // Get computer in gin context to add it in token, to used it in file template. 62 | token.Computer = *c.MustGet("computer").(*models.Computer) 63 | db.Create(&token) 64 | } 65 | return fmt.Sprintf("%s%s", baseURL.String(), path), err 66 | }, 67 | "GetTagValue": func(name string) (ret string, err error) { 68 | computer := c.MustGet("computer").(*models.Computer) 69 | if computer == nil { 70 | return "", fmt.Errorf("failed to GetTagValue because computer is nil") 71 | } 72 | for _, tag := range computer.Tags { 73 | if tag.Key == name { 74 | return tag.Value, err 75 | } 76 | } 77 | return "", err 78 | }, 79 | "GetComputerName": func() (ret string, err error) { 80 | computer := c.MustGet("computer").(*models.Computer) 81 | if computer == nil { 82 | return "", fmt.Errorf("failed to GetTagValue because computer is nil") 83 | } 84 | return computer.Name, nil 85 | }, 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /models/computer.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/google/uuid" 6 | "github.com/jackc/pgtype" 7 | "time" 8 | ) 9 | 10 | type Computer struct { 11 | Uuid uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` 12 | Mac pgtype.Macaddr `gorm:"type:macaddr;index:idx_mac" json:"-"` 13 | IP pgtype.Inet `gorm:"type:inet;index:idx_ip" json:"-"` 14 | Asset string `json:"asset"` 15 | BuildArch string `json:"build_arch"` 16 | Hostname string `json:"hostname"` 17 | LastSeen time.Time `json:"last_seen"` 18 | Manufacturer string `json:"manufacturer"` 19 | Name string `json:"name"` 20 | Platform string `json:"platform"` 21 | Product string `json:"product"` 22 | Serial string `json:"serial"` 23 | Version string `json:"version"` 24 | Tags []*Tag `gorm:"foreignkey:computer_uuid;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"tags"` 25 | Bootorder []*Bootorder `gorm:"foreignKey:computer_uuid;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-"` 26 | LastIpxeaccountID *string `json:"last_ipxeaccount"` 27 | LastIpxeaccount *Ipxeaccount `gorm:"foreignkey:last_ipxeaccount_id;References:Username;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;default:NULL" json:"-"` 28 | CreatedAt time.Time `json:"created_at"` 29 | UpdatedAt time.Time `json:"updated_at"` 30 | } 31 | 32 | var ComputerSearchFields = []string{ 33 | "name", 34 | "hostname", 35 | "mac", 36 | "ip", 37 | "serial", 38 | } 39 | 40 | // MarshalJSON initializes nil slices and then marshals the bag to JSON 41 | func (c Computer) MarshalJSON() ([]byte, error) { 42 | if c.Tags == nil { 43 | c.Tags = make([]*Tag, 0) 44 | } 45 | 46 | type Alias Computer 47 | 48 | // List of Bootentry ID for json output 49 | bootorder := make([]*Bootentry, len(c.Bootorder)) 50 | for i, bootentry := range c.Bootorder { 51 | bootorder[i] = bootentry.Bootentry 52 | } 53 | 54 | return json.Marshal(&struct { 55 | Mac string `json:"mac"` 56 | IP string `json:"ip"` 57 | Bootorder []*Bootentry `json:"bootorder"` 58 | Alias 59 | }{ 60 | Mac: c.Mac.Addr.String(), 61 | IP: c.IP.IPNet.IP.String(), 62 | Bootorder: bootorder, 63 | Alias: (Alias)(c), 64 | }) 65 | 66 | } 67 | 68 | func (c *Computer) UnmarshalJSON(data []byte) error { 69 | type Alias Computer 70 | aux := &struct { 71 | Mac string `json:"mac"` 72 | IP string `json:"ip"` 73 | Bootorder []*Bootentry `json:"bootorder"` 74 | *Alias 75 | }{ 76 | Alias: (*Alias)(c), 77 | } 78 | if err := json.Unmarshal(data, &aux); err != nil { 79 | return err 80 | } 81 | if err := c.Mac.DecodeText(nil, []byte(aux.Mac)); err != nil { 82 | return err 83 | } 84 | if err := c.IP.DecodeText(nil, []byte(aux.IP)); err != nil { 85 | return err 86 | } 87 | // build the bootorder list 88 | c.Bootorder = make([]*Bootorder, len(aux.Bootorder)) 89 | for i, bootentry := range aux.Bootorder { 90 | c.Bootorder[i] = &Bootorder{ 91 | Order: i, 92 | BootentryUuid: bootentry.Uuid, 93 | ComputerUuid: c.Uuid, 94 | } 95 | } 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /controllers/tftp.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/aarnaud/ipxeblue/utils" 7 | "github.com/pin/tftp/v3" 8 | "github.com/rs/zerolog/log" 9 | "gorm.io/gorm" 10 | "io" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "path/filepath" 15 | "strings" 16 | ) 17 | 18 | func GetTFTPReader(config *utils.Config, db *gorm.DB) func(filename string, rf io.ReaderFrom) error { 19 | folder := "tftp" 20 | return func(filename string, rf io.ReaderFrom) error { 21 | filename = strings.TrimRight(filename, "�") 22 | raddr := rf.(tftp.OutgoingTransfer).RemoteAddr() 23 | log.Info().Msgf("RRQ from %s filename %s", raddr.String(), filename) 24 | 25 | path := filepath.Join(folder, filename) 26 | 27 | // if file doesn't exist and path start by /grub/ use grubTFTP2HTTP 28 | if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) && strings.HasPrefix(filename, "/grub/") { 29 | return grubTFTP2HTTP(config, db, filename, rf) 30 | } 31 | 32 | file, err := os.Open(path) 33 | if err != nil { 34 | log.Error().Err(err) 35 | return err 36 | } 37 | stat, err := file.Stat() 38 | if err != nil { 39 | log.Error().Err(err) 40 | return err 41 | } 42 | rf.(tftp.OutgoingTransfer).SetSize(stat.Size()) 43 | _, err = rf.ReadFrom(file) 44 | if err != nil { 45 | log.Error().Err(err) 46 | return err 47 | } 48 | return nil 49 | } 50 | } 51 | 52 | func GetTFTPWriter(config *utils.Config) func(filename string, wt io.WriterTo) error { 53 | return func(filename string, wt io.WriterTo) error { 54 | return nil 55 | } 56 | } 57 | 58 | func grubTFTP2HTTP(config *utils.Config, db *gorm.DB, filename string, rf io.ReaderFrom) error { 59 | gruburl, _ := url.Parse(config.BaseURL.String()) 60 | if filename == "/grub/grub.cfg" { 61 | gruburl = gruburl.JoinPath("/grub/") 62 | resp, err := http.Get(gruburl.String()) 63 | if err != nil { 64 | return err 65 | } 66 | defer resp.Body.Close() 67 | b, err := io.ReadAll(resp.Body) 68 | if err != nil { 69 | return err 70 | } 71 | reader := strings.NewReader(string(b)) 72 | _, err = rf.ReadFrom(reader) 73 | if err != nil { 74 | return err 75 | } 76 | return nil 77 | } 78 | 79 | paths := strings.Split(filename, "/") 80 | if len(paths) < 11 { 81 | return fmt.Errorf("invalid path") 82 | } 83 | gruburl = gruburl.JoinPath("/grub/") 84 | query := gruburl.Query() 85 | query.Add("mac", paths[2]) 86 | query.Add("ip", paths[3]) 87 | query.Add("uuid", paths[4]) 88 | query.Add("asset", strings.TrimSpace(strings.TrimLeft(paths[5], "-"))) 89 | query.Add("manufacturer", strings.TrimLeft(paths[6], "-")) 90 | query.Add("serial", strings.TrimLeft(paths[7], "-")) 91 | query.Add("product", strings.TrimLeft(paths[8], "-")) 92 | query.Add("buildarch", strings.TrimLeft(paths[9], "-")) 93 | query.Add("platform", strings.TrimLeft(paths[10], "-")) 94 | gruburl.RawQuery = query.Encode() 95 | resp, err := http.Get(gruburl.String()) 96 | if err != nil { 97 | return err 98 | } 99 | defer resp.Body.Close() 100 | b, err := io.ReadAll(resp.Body) 101 | if err != nil { 102 | return err 103 | } 104 | reader := strings.NewReader(string(b)) 105 | _, err = rf.ReadFrom(reader) 106 | if err != nil { 107 | return err 108 | } 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /webui/src/App.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Admin, Resource} from 'react-admin'; 3 | import jsonServerProvider from 'ra-data-json-server'; 4 | import { ComputerList, ComputerEdit } from './models/computers' 5 | import { IpxeaccountList, IpxeaccountCreate, IpxeaccountEdit } from './models/ipxeaccount' 6 | import { BootentryList, BootentryCreate, BootentryEdit } from './models/bootentry' 7 | import "./App.css" 8 | import ComputerIcon from '@material-ui/icons/Computer'; 9 | import VpnKeyIcon from '@material-ui/icons/VpnKey'; 10 | import AssignmentIcon from '@material-ui/icons/Assignment'; 11 | 12 | import { createMuiTheme } from '@material-ui/core/styles'; 13 | import blueGrey from '@material-ui/core/colors/blueGrey'; 14 | import lightBlue from '@material-ui/core/colors/lightBlue'; 15 | const theme = createMuiTheme({ 16 | typography: { 17 | fontSize: 12, 18 | }, 19 | palette: { 20 | primary: lightBlue, 21 | secondary: blueGrey, 22 | type: 'dark', // Switching the dark mode on is a single property value change. 23 | }, 24 | }); 25 | 26 | 27 | const apiUrl = '/api/v1'; 28 | const dataProvider = jsonServerProvider(apiUrl); 29 | const myDataProvider = { 30 | ...dataProvider, 31 | update: (resource, params) => { 32 | if (resource !== 'bootentries' || !params.data.files) { 33 | // fallback to the default implementation 34 | return dataProvider.update(resource, params); 35 | } 36 | 37 | // remove file if fileobject is null 38 | params.data.files = params.data.files.filter(file => { 39 | if (file === undefined){ 40 | return false 41 | } 42 | return file.file !== null 43 | }) 44 | 45 | // set name from fileobject title 46 | params.data.files.map(file => { 47 | if (file.file) { 48 | file.name = file.file.title 49 | } 50 | return file 51 | }) 52 | 53 | // if rawfile exist it's new file, need to be upload 54 | const newFiles = params.data.files.filter( 55 | file => { 56 | return file.file.rawFile instanceof File 57 | } 58 | ); 59 | return Promise.all(newFiles.map(file => { 60 | return fileUpload(file.file, `${apiUrl}/${resource}/${params.id}/files/${file.name}`) 61 | })).then(files => 62 | dataProvider.update(resource, params) 63 | ) 64 | }, 65 | }; 66 | 67 | const fileUpload = (file, url) => { 68 | const formData = new FormData() 69 | formData.append('file', file.rawFile) 70 | return fetch(url, { 71 | method: 'POST', 72 | body: formData 73 | }) 74 | .then(response => response.json()) 75 | .then(data => { 76 | console.log(data) 77 | }) 78 | .catch(error => { 79 | console.error(error) 80 | }) 81 | } 82 | 83 | 84 | 85 | const App = () => ( 86 | 87 | 88 | 89 | 90 | 91 | ); 92 | 93 | export default App; 94 | -------------------------------------------------------------------------------- /webui/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `yarn build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /webui/src/models/bootentry.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | ArrayInput, 4 | BooleanField, 5 | BooleanInput, 6 | Create, 7 | Datagrid, 8 | DateField, 9 | DateTimeInput, 10 | Edit, 11 | EditButton, 12 | FileField, 13 | FileInput, 14 | Filter, 15 | List, 16 | Pagination, 17 | SimpleForm, 18 | SimpleFormIterator, 19 | TextField, 20 | TextInput, 21 | required, 22 | } from 'react-admin'; 23 | 24 | const PostPagination = props => ; 25 | const EditTitle = ({ record }) => { 26 | return {record ? `${record.name}` : ''}; 27 | }; 28 | 29 | const BootentryFilter = (props) => ( 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | 37 | export const BootentryList = props => ( 38 | } perPage={15} filters={} sort={{ field: 'name', order: 'ASC' }} {...props}> 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ); 50 | 51 | export const BootentryCreate = props => ( 52 | // undoable={false} disable optimistic rendering 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ); 63 | 64 | export const BootentryEdit = props => ( 65 | } {...props}> 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | ); -------------------------------------------------------------------------------- /controllers/generic.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aarnaud/ipxeblue/models" 6 | "github.com/aarnaud/ipxeblue/utils/helpers" 7 | "github.com/gin-gonic/gin" 8 | "gorm.io/gorm" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") 15 | var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") 16 | 17 | func ToSnakeCase(str string) string { 18 | snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") 19 | snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") 20 | return strings.ToLower(snake) 21 | } 22 | 23 | func ListFilter(db *gorm.DB, c *gin.Context) *gorm.DB { 24 | var order string = "ASC" 25 | if value, existe := c.GetQuery("_order"); existe { 26 | order = value 27 | } 28 | if value, exist := c.GetQuery("_sort"); exist { 29 | // react-admin use id as primary key, so we convert the key depends of object 30 | value = ConvertReactAdminID(c, value) 31 | db = db.Order(fmt.Sprintf("%s %s", ToSnakeCase(value), order)) 32 | } 33 | 34 | if value, exist := c.GetQuery("q"); exist { 35 | query := strings.Builder{} 36 | fields := SearchFields(c) 37 | queryvalues := make([]interface{}, len(fields)) 38 | for i, field := range fields { 39 | if query.Len() != 0 { 40 | query.WriteString(" OR ") 41 | } 42 | queryvalues[i] = fmt.Sprintf("%%%s%%", value) 43 | query.WriteString(fmt.Sprintf("%s ILIKE ?", field)) 44 | } 45 | db = db.Where(query.String(), queryvalues...) 46 | 47 | } else { 48 | for q, v := range c.Request.URL.Query() { 49 | if strings.HasPrefix(q, "_") { 50 | continue 51 | } 52 | // react-admin use id as primary key, so we convert the key depends of object 53 | q = ConvertReactAdminID(c, q) 54 | if len(v) == 1 { 55 | value := v[0] 56 | if helpers.StringToType(value) != helpers.TYPE_BOOL && helpers.StringToType(value) != helpers.TYPE_UUID && 57 | q != "ip" && q != "mac" { 58 | // hack to manage key value search 59 | if q == "tag" && strings.Contains(value, "=") { 60 | keyandvalue := strings.Split(value, "=") 61 | db = db.Where("key ILIKE ?", fmt.Sprintf("%%%s%%", keyandvalue[0])) 62 | db = db.Where("value ILIKE ?", fmt.Sprintf("%%%s%%", keyandvalue[1])) 63 | continue 64 | } 65 | db = db.Where(fmt.Sprintf("%s ILIKE ?", q), fmt.Sprintf("%%%s%%", value)) 66 | } else { 67 | db = db.Where(fmt.Sprintf("%s = ?", q), value) 68 | } 69 | } 70 | if len(v) > 1 { 71 | db = db.Where(fmt.Sprintf("%s IN ?", q), v) 72 | } 73 | } 74 | } 75 | 76 | return db 77 | } 78 | 79 | func PaginationFilter(db *gorm.DB, c *gin.Context) *gorm.DB { 80 | var err error 81 | var start int = 0 82 | if value, exist := c.GetQuery("_start"); exist { 83 | start, err = strconv.Atoi(value) 84 | if err == nil { 85 | db = db.Offset(start) 86 | } 87 | //todo: log of failed 88 | } 89 | if value, exist := c.GetQuery("_end"); exist { 90 | if end, err := strconv.Atoi(value); err == nil { 91 | db = db.Limit(end - start) 92 | } 93 | //todo: log of failed 94 | } 95 | return db 96 | } 97 | 98 | func ConvertReactAdminID(c *gin.Context, key string) string { 99 | if key == "id" && strings.Contains(c.FullPath(), "/computers") { 100 | return "uuid" 101 | } 102 | if key == "id" && strings.Contains(c.FullPath(), "/bootentries") { 103 | return "uuid" 104 | } 105 | if key == "id" && strings.Contains(c.FullPath(), "/ipxeaccounts") { 106 | return "username" 107 | } 108 | return key 109 | } 110 | 111 | func SearchFields(c *gin.Context) []string { 112 | if strings.Contains(c.FullPath(), "/computers") { 113 | return models.ComputerSearchFields 114 | } 115 | if strings.Contains(c.FullPath(), "/bootentries") { 116 | return models.BootentrySearchFields 117 | } 118 | if strings.Contains(c.FullPath(), "/ipxeaccounts") { 119 | return models.IpxeAccountSearchFields 120 | } 121 | return []string{ 122 | "id", 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /controllers/grub.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/aarnaud/ipxeblue/models" 7 | "github.com/aarnaud/ipxeblue/utils" 8 | "github.com/gin-gonic/gin" 9 | "github.com/google/uuid" 10 | "github.com/jackc/pgtype" 11 | "gorm.io/gorm" 12 | "net/http" 13 | "text/template" 14 | ) 15 | 16 | func GrubScript(c *gin.Context) { 17 | config := c.MustGet("config").(*utils.Config) 18 | 19 | // basic check or reply with ipxe chain 20 | _, uuidExist := c.GetQuery("uuid") 21 | _, macExist := c.GetQuery("mac") 22 | _, ipExist := c.GetQuery("ip") 23 | if !uuidExist || !macExist || !ipExist { 24 | baseURL := *config.BaseURL 25 | // use the same scheme from request to generate URL 26 | if schem := c.Request.Header.Get("X-Forwarded-Proto"); schem != "" { 27 | baseURL.Scheme = schem 28 | } 29 | c.HTML(http.StatusOK, "grub_index.gohtml", gin.H{ 30 | "BaseURL": config.BaseURL.String(), 31 | "Scheme": config.BaseURL.Scheme, 32 | "Host": config.BaseURL.Host, 33 | }) 34 | return 35 | } 36 | 37 | // process query params 38 | db := c.MustGet("db").(*gorm.DB) 39 | id, err := uuid.Parse(c.Query("uuid")) 40 | if err != nil { 41 | c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{ 42 | "error": err.Error(), 43 | }) 44 | return 45 | } 46 | 47 | mac := pgtype.Macaddr{} 48 | err = mac.DecodeText(nil, []byte(c.Query("mac"))) 49 | if err != nil { 50 | c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{ 51 | "error": err.Error(), 52 | }) 53 | return 54 | } 55 | 56 | ip := pgtype.Inet{} 57 | err = ip.DecodeText(nil, []byte(c.Query("ip"))) 58 | if err != nil { 59 | c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{ 60 | "error": err.Error(), 61 | }) 62 | return 63 | } 64 | 65 | computer := updateOrCreateComputer(c, id, mac, ip) 66 | // Add computer in gin context to use it in template function 67 | c.Set("computer", &computer) 68 | 69 | c.Header("Content-Type", "text/plain; charset=utf-8") 70 | bootorder := models.Bootorder{} 71 | result := db.Preload("Bootentry").Preload("Bootentry.Files"). 72 | Where("computer_uuid = ?", computer.Uuid).Order("bootorders.order").First(&bootorder) 73 | if result.RowsAffected == 0 { 74 | c.HTML(http.StatusOK, "grub_empty.gohtml", gin.H{}) 75 | return 76 | } 77 | bootentry := bootorder.Bootentry 78 | 79 | // Create template name by the uuid 80 | tpl := template.New(bootentry.Uuid.String()) 81 | // provide a func in the FuncMap which can access tpl to be able to look up templates 82 | tpl.Funcs(utils.GetCustomFunctions(c, tpl)) 83 | 84 | tpl, err = tpl.Parse(bootentry.GrupScript) 85 | if err != nil { 86 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ 87 | "error": err.Error(), 88 | }) 89 | return 90 | } 91 | 92 | writer := bytes.NewBuffer([]byte{}) 93 | writer.Write([]byte("set timeout=2\n")) 94 | writer.Write([]byte(fmt.Sprintf("set prefix=(http,%s)\n", config.BaseURL.Host))) 95 | writer.Write([]byte(fmt.Sprintf("echo 'Booting %s'\n", bootentry.Description))) 96 | 97 | // if bootentry selected is menu load all bootentries as template 98 | if bootentry.Name == "menu" { 99 | bootentries := make([]models.Bootentry, 0) 100 | db.Preload("Files").Where("name != 'menu'").Find(&bootentries) 101 | for _, be := range bootentries { 102 | // test if empty 103 | tpl.New(be.Uuid.String()).Parse(be.GrupScript) 104 | } 105 | err = tpl.ExecuteTemplate(writer, bootentry.Uuid.String(), gin.H{ 106 | "Computer": computer, 107 | "Bootentry": bootentry, 108 | "Bootentries": bootentries, 109 | }) 110 | if err != nil { 111 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ 112 | "error": err.Error(), 113 | }) 114 | return 115 | } 116 | } else { 117 | err = tpl.ExecuteTemplate(writer, bootentry.Uuid.String(), bootentry) 118 | if err != nil { 119 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ 120 | "error": err.Error(), 121 | }) 122 | return 123 | } 124 | } 125 | 126 | // reset bootentry if not persistent 127 | if !*bootentry.Persistent { 128 | db.Model(&bootorder).Delete(&bootorder) 129 | } 130 | 131 | c.Data(http.StatusOK, "text/plain", writer.Bytes()) 132 | } 133 | -------------------------------------------------------------------------------- /webui/src/models/computers.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | ArrayField, 4 | ArrayInput, 5 | ChipField, 6 | Datagrid, 7 | DateField, 8 | DateTimeInput, 9 | DeleteButton, 10 | Edit, 11 | EditButton, 12 | Filter, 13 | List, 14 | Pagination, 15 | ReferenceInput, 16 | SelectInput, 17 | SimpleForm, 18 | SimpleFormIterator, 19 | SingleFieldList, 20 | TextField, 21 | TextInput, 22 | required, 23 | } from 'react-admin'; 24 | 25 | const PostPagination = props => ; 26 | const EditTitle = ({ record }) => { 27 | return {record ? `${record.name}` : ''}; 28 | }; 29 | 30 | const ComputerFilter = (props) => ( 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | 47 | export const ComputerList = props => ( 48 | } perPage={15} filters={} sort={{ field: 'name', order: 'ASC' }} {...props}> 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | ); 73 | 74 | export const ComputerEdit = props => ( 75 | } {...props}> 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | ); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ipxeblue : iPXE management 2 | 3 | > Manage network boot over HTTPS with iPXE with admin WebUI and API 4 | 5 | [Go to screenshots](#screenshots) 6 | 7 | ## features 8 | 9 | - auto create Computer object on first network boot 10 | - manage bootentry 11 | - ipxe script field 12 | - upload files for boot 13 | - manage iPXE account for basic auth 14 | - https support 15 | - iPXE menu that list all bootentries 16 | 17 | ## config 18 | 19 | Supported environment variables 20 | 21 | - `PORT` used to bind http server 22 | - default: `8080` 23 | - `BASE_URL` used in template to defined the public URL 24 | - default: `http://127.0.0.1:8080` 25 | - `ENABLE_API_AUTH` can be switch to `false` to used SSO proxy in front of API 26 | - default: `true` 27 | - `DATABASE_URL` postgres URL, example `postgres://user:passworkd@localhost:5432/ipxeblue?sslmode=disable` 28 | - `MINIO_ENDPOINT` S3 compatible endpoint 29 | - default: `127.0.0.1:9000` 30 | - `MINIO_ACCESS_KEY` 31 | - `MINIO_SECRET_KEY` 32 | - `MINIO_SECURE` 33 | - `MINIO_BUCKETNAME` 34 | - default: `ipxeblue` 35 | - `GRUB_SUPPORT_ENABLED` 36 | - default: `False` 37 | - `TFTP_ENABLED` 38 | - default: `False` 39 | - `DEFAULT_BOOTENTRY_NAME` 40 | - default: `` 41 | 42 | ## DHCP or ipxe config for connection to ipxeblue 43 | 44 | For embed ipxe script or chainload 45 | ```shell 46 | ifstat || 47 | dhcp || 48 | route || 49 | set crosscert http://ca.ipxe.org/auto 50 | chain https://USERNAME:PASSWORD@FQDN/?asset=${asset}&buildarch=${buildarch}&hostname=${hostname}&mac=${mac:hexhyp}&ip=${ip}&manufacturer=${manufacturer}&platform=${platform}&product=${product}&serial=${serial}&uuid=${uuid}&version=${version} 51 | ``` 52 | 53 | ### For isc-dhcp-server 54 | 55 | you need to set `iPXE-specific options` see https://ipxe.org/howto/dhcpd 56 | 57 | ```text 58 | if option arch = 00:07 { 59 | filename "snponly.efi"; 60 | } else { 61 | filename "undionly.kpxe"; 62 | } 63 | if exists user-class and option user-class = "iPXE" and exists ipxe.https { 64 | option ipxe.crosscert "http://ca.ipxe.org/auto"; 65 | option ipxe.username "demo"; 66 | option ipxe.password "demo"; 67 | filename "https://ipxeblue.yourdomain/"; 68 | } 69 | # a TFTP server to load iPXE if not already load by default 70 | next-server 10.123.123.123; 71 | ``` 72 | 73 | ### For kea-dhcp-server 74 | ```text 75 | "Dhcp4": { 76 | ... 77 | "option-def": [ 78 | { "space": "dhcp4", "name": "ipxe-encap-opts", "code": 175, "type": "empty", "array": false, "record-types": "", "encapsulate": "ipxe" }, 79 | { "space": "ipxe", "name": "crosscert", "code": 93, "type": "string" }, 80 | { "space": "ipxe", "name": "username", "code": 190, "type": "string" }, 81 | { "space": "ipxe", "name": "password", "code": 191, "type": "string" } 82 | ], 83 | "client-classes": [ 84 | { 85 | "name": "XClient_iPXE", 86 | "test": "substring(option[77].hex,0,4) == 'iPXE'", 87 | "boot-file-name": "ipxeblue.ipxe", 88 | "option-data": [ 89 | { "space": "dhcp4", "name": "ipxe-encap-opts", "code": 175 }, 90 | { "space": "ipxe", "name": "crosscert", "data": "http://ca.ipxe.org/auto" }, 91 | { "space": "ipxe", "name": "username", "data": "demo" }, 92 | { "space": "ipxe", "name": "password", "data": "demo" } 93 | ] 94 | }, 95 | { 96 | "name": "UEFI-64", 97 | "test": "substring(option[60].hex,0,20) == 'PXEClient:Arch:00007'", 98 | "boot-file-name": "snponly.efi" 99 | }, 100 | { 101 | "name": "Legacy", 102 | "test": "substring(option[60].hex,0,20) == 'PXEClient:Arch:00000'", 103 | "boot-file-name": "undionly.kpxe" 104 | } 105 | ], 106 | "subnet4": [ 107 | { 108 | ... 109 | "next-server": "10.123.123.123", 110 | ... 111 | } 112 | ] 113 | ... 114 | } 115 | ``` 116 | 117 | ### Grub over PXE: 118 | > Secure Boot supported with signed binaries 119 | 120 | /srv/tftp/bootx64.efi (sha256sum 8c885fa9886ab668da267142c7226b8ce475e682b99e4f4afc1093c5f77ce275) 121 | /srv/tftp/grubx64.efi (sha256sum d0d6d85f44a0ffe07d6a856ad5a1871850c31af17b7779086b0b9384785d5449) 122 | /srv/tftp/grub/grub.cfg 123 | ```text 124 | insmod http 125 | source (http,192.168.32.7)/grub/ 126 | ``` 127 | 128 | ## screenshots 129 | 130 | ![Computer List](docs/images/computer-list.png?raw=true "Computer List") 131 | ![Computer Edit](docs/images/computer-edit.png?raw=true "Computer Edit") 132 | ![Account List](docs/images/account-list.png?raw=true "Account List") 133 | ![Bootentry List](docs/images/bootentry-list.png?raw=true "Bootentry List") 134 | ![Bootentry Edit](docs/images/bootentry-edit.png?raw=true "Bootentry List") -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aarnaud/ipxeblue/controllers" 6 | _ "github.com/aarnaud/ipxeblue/docs" 7 | "github.com/aarnaud/ipxeblue/midlewares" 8 | "github.com/aarnaud/ipxeblue/utils" 9 | "github.com/gin-contrib/cors" 10 | "github.com/gin-contrib/logger" 11 | "github.com/gin-gonic/gin" 12 | "github.com/pin/tftp/v3" 13 | "github.com/rs/zerolog" 14 | "github.com/rs/zerolog/log" 15 | swaggerFiles "github.com/swaggo/files" 16 | ginSwagger "github.com/swaggo/gin-swagger" 17 | "net/http" 18 | "os" 19 | "time" 20 | ) 21 | 22 | // @title ipxeblue API 23 | // @version 0.1 24 | // @description Manage PXE boot 25 | // @termsOfService http://swagger.io/terms/ 26 | 27 | // @license.name Apache 2.0 28 | // @license.url http://www.apache.org/licenses/LICENSE-2.0.html 29 | 30 | // @host localhost:8080 31 | // @BasePath /api/v1 32 | func main() { 33 | zerolog.SetGlobalLevel(zerolog.InfoLevel) 34 | appconf := utils.GetConfig() 35 | 36 | router := gin.New() 37 | router.Use(logger.SetLogger(logger.Config{ 38 | SkipPath: []string{"/healthz"}, 39 | })) 40 | router.Use(gin.Recovery()) 41 | 42 | // CORS for https://foo.com and https://github.com origins, allowing: 43 | // - PUT and PATCH methods 44 | // - Origin header 45 | // - Credentials share 46 | // - Preflight requests cached for 12 hours 47 | router.Use(cors.New(cors.Config{ 48 | AllowOrigins: []string{"http://localhost:3000", "http://localhost:8080"}, 49 | AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, 50 | AllowHeaders: []string{"Origin", "Content-Type"}, 51 | ExposeHeaders: []string{"x-total-count", "content-length"}, 52 | AllowCredentials: true, 53 | MaxAge: 12 * time.Hour, 54 | })) 55 | 56 | router.LoadHTMLGlob("templates/*") 57 | db := utils.Database() 58 | filestore := utils.NewFileStore(appconf) 59 | log.Info().Msg("starting TokenCleaner in goroutine") 60 | go utils.TokenCleaner(db) 61 | 62 | // Provide db variable to controllers 63 | router.Use(func(c *gin.Context) { 64 | c.Set("db", db) 65 | c.Set("filestore", filestore) 66 | c.Set("config", appconf) 67 | c.Header("Cache-Control", "no-store, max-age=0") 68 | c.Next() 69 | }) 70 | 71 | if gin.Mode() == gin.DebugMode { 72 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 73 | // Configure SwaggerUI 74 | url := ginSwagger.URL("http://localhost:8080/swagger/doc.json") // The url pointing to API definition 75 | router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url)) 76 | // proxies UI call to nodejs react server 77 | router.Use(midlewares.MidlewareDevWebUI()) 78 | } else { 79 | // Serve react-admin webui 80 | router.Static("/admin", "./admin") 81 | } 82 | 83 | router.GET("/healthz", func(c *gin.Context) { 84 | c.JSON(http.StatusOK, gin.H{}) 85 | }) 86 | 87 | if appconf.GrubSupportEnabled { 88 | // Grub request without auth 89 | log.Info().Msg("enabling grub http endpoint") 90 | router.GET("/grub/", controllers.GrubScript) 91 | } 92 | 93 | // grub don't support long http queries 94 | // using TFTP to pass positional metadata in tftp path 95 | if appconf.TFTPEnabled { 96 | s := tftp.NewServer(controllers.GetTFTPReader(appconf, db), controllers.GetTFTPWriter(appconf)) 97 | s.SetTimeout(30 * time.Second) 98 | go func() { 99 | log.Info().Msg("starting tftp server") 100 | err := s.ListenAndServe(":69") 101 | if err != nil { 102 | fmt.Fprintf(os.Stdout, "server: %v\n", err) 103 | } 104 | }() 105 | } 106 | 107 | // iPXE request with auth 108 | ipxeroute := router.Group("/", midlewares.BasicAuthIpxeAccount(false)) 109 | ipxeroute.GET("/", controllers.IpxeScript) 110 | 111 | router.HEAD("/files/public/:uuid/*filepath", controllers.DownloadPublicFile) 112 | router.GET("/files/public/:uuid/*filepath", controllers.DownloadPublicFile) 113 | router.HEAD("/files/token/:token/:uuid/*filepath", controllers.DownloadProtectedFile) 114 | router.GET("/files/token/:token/:uuid/*filepath", controllers.DownloadProtectedFile) 115 | 116 | var v1 *gin.RouterGroup 117 | if appconf.EnableAPIAuth { 118 | // API 119 | v1 = router.Group("/api/v1", midlewares.BasicAuthIpxeAccount(true)) 120 | } else { 121 | // API 122 | v1 = router.Group("/api/v1") 123 | } 124 | 125 | // Computer 126 | v1.GET("/computers", controllers.ListComputers) 127 | v1.GET("/computers/:id", controllers.GetComputer) 128 | v1.PUT("/computers/:id", controllers.UpdateComputer) 129 | v1.DELETE("/computers/:id", controllers.DeleteComputer) 130 | 131 | // IPXE account 132 | v1.GET("/ipxeaccounts", controllers.ListIpxeaccount) 133 | v1.GET("/ipxeaccounts/:username", controllers.GetIpxeaccount) 134 | v1.POST("/ipxeaccounts", controllers.CreateIpxeaccount) 135 | v1.PUT("/ipxeaccounts/:username", controllers.UpdateIpxeaccount) 136 | v1.DELETE("/ipxeaccounts/:username", controllers.DeleteIpxeaccount) 137 | 138 | // Bootentry 139 | v1.GET("/bootentries", controllers.ListBootentries) 140 | v1.GET("/bootentries/:uuid", controllers.GetBootentry) 141 | v1.POST("/bootentries", controllers.CreateBootentry) 142 | v1.PUT("/bootentries/:uuid", controllers.UpdateBootentry) 143 | v1.DELETE("/bootentries/:uuid", controllers.DeleteBootentry) 144 | v1.POST("/bootentries/:uuid/files/:name", controllers.UploadBootentryFile) 145 | v1.GET("/bootentries/:uuid/files/:name", controllers.DownloadBootentryFile) 146 | 147 | log.Info().Msg("starting http server") 148 | router.Run(fmt.Sprintf(":%d", appconf.Port)) 149 | } 150 | -------------------------------------------------------------------------------- /controllers/ipxeaccount.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aarnaud/ipxeblue/models" 6 | "github.com/gin-gonic/gin" 7 | "gorm.io/gorm" 8 | "net/http" 9 | "strconv" 10 | ) 11 | 12 | // ListIpxeaccount 13 | // @Summary List iPXE account 14 | // @Description List of accounts for ipxe 15 | // @Accept json 16 | // @Produce json 17 | // @Param _start query int false "Offset" 18 | // @Success 200 {object} []models.Ipxeaccount 19 | // @Router /ipxeaccount [get] 20 | func ListIpxeaccount(c *gin.Context) { 21 | db := c.MustGet("db").(*gorm.DB) 22 | 23 | var total int64 24 | db = ListFilter(db, c) 25 | db.Model(&models.Ipxeaccount{}).Count(&total) 26 | c.Header("X-Total-Count", strconv.FormatInt(total, 10)) 27 | 28 | logins := make([]models.Ipxeaccount, 0) 29 | db = PaginationFilter(db, c) 30 | db.Find(&logins) 31 | c.JSON(http.StatusOK, logins) 32 | } 33 | 34 | // GetIpxeaccount 35 | // @Summary Get iPXE account 36 | // @Description Get iPXE account by username 37 | // @Accept json 38 | // @Produce json 39 | // @Param username path string true "Username" 40 | // @Success 200 {object} models.Ipxeaccount 41 | // @Failure 404 {object} models.Error "iPXE account not found" 42 | // @Router /ipxeaccount/{username} [get] 43 | func GetIpxeaccount(c *gin.Context) { 44 | db := c.MustGet("db").(*gorm.DB) 45 | 46 | username := c.Param("username") 47 | 48 | account := models.Ipxeaccount{} 49 | result := db.Where("username = ?", username).First(&account) 50 | if result.RowsAffected == 0 { 51 | c.AbortWithStatusJSON(http.StatusNotFound, models.Error{ 52 | Error: fmt.Sprintf("iPXE account with username %s not found", username), 53 | }) 54 | return 55 | } 56 | c.JSON(http.StatusOK, account) 57 | } 58 | 59 | // CreateIpxeaccount 60 | // @Summary Create iPXE account 61 | // @Description Create a iPXE account 62 | // @Accept json 63 | // @Produce json 64 | // @Param ipxeaccount body models.Ipxeaccount true "json format iPXE account" 65 | // @Success 200 {object} models.Ipxeaccount 66 | // @Failure 400 {object} models.Error "Failed to create account in DB" 67 | // @Failure 500 {object} models.Error "Unmarshall error" 68 | // @Router /ipxeaccount [post] 69 | func CreateIpxeaccount(c *gin.Context) { 70 | db := c.MustGet("db").(*gorm.DB) 71 | 72 | accountToCreate := models.Ipxeaccount{} 73 | err := c.Bind(&accountToCreate) 74 | if err != nil { 75 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 76 | Error: err.Error(), 77 | }) 78 | return 79 | } 80 | 81 | result := db.Create(&accountToCreate) 82 | 83 | if result.RowsAffected == 0 { 84 | c.AbortWithStatusJSON(http.StatusBadRequest, models.Error{ 85 | Error: fmt.Sprintf("iPXE account not create, unknown error"), 86 | }) 87 | return 88 | } 89 | 90 | // refresh data from DB before return it 91 | account := models.Ipxeaccount{} 92 | db.First(&account, "username = ?", accountToCreate.Username) 93 | 94 | c.JSON(http.StatusOK, account) 95 | } 96 | 97 | // UpdateIpxeaccount 98 | // @Summary Update iPXE account 99 | // @Description Update a iPXE account 100 | // @Accept json 101 | // @Produce json 102 | // @Param username path string true "Username" 103 | // @Param ipxeaccount body models.Ipxeaccount true "json format iPXE account" 104 | // @Success 200 {object} models.Ipxeaccount 105 | // @Failure 500 {object} models.Error "Unmarshall error" 106 | // @Failure 400 {object} models.Error "Query username and username miss match" 107 | // @Failure 404 {object} models.Error "iPXE account not found" 108 | // @Router /ipxeaccount/{username} [put] 109 | func UpdateIpxeaccount(c *gin.Context) { 110 | db := c.MustGet("db").(*gorm.DB) 111 | username := c.Param("username") 112 | 113 | accountUpdate := models.Ipxeaccount{} 114 | err := c.Bind(&accountUpdate) 115 | if err != nil { 116 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 117 | Error: err.Error(), 118 | }) 119 | return 120 | } 121 | 122 | if accountUpdate.Username != username { 123 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Usernames missmatch"}) 124 | return 125 | } 126 | 127 | result := db.Model(&accountUpdate).Updates(accountUpdate) 128 | 129 | if result.RowsAffected == 0 { 130 | c.AbortWithStatusJSON(http.StatusNotFound, models.Error{ 131 | Error: fmt.Sprintf("iPXE account with username %s not found", username), 132 | }) 133 | return 134 | } 135 | 136 | // refresh data from DB before return it 137 | account := models.Ipxeaccount{} 138 | db.First(&account, "username = ?", accountUpdate.Username) 139 | 140 | c.JSON(http.StatusOK, account) 141 | } 142 | 143 | // DeleteIpxeaccount 144 | // @Summary Delete iPXE account 145 | // @Description Delete a iPXE account 146 | // @Accept json 147 | // @Produce json 148 | // @Param username path string true "Username" 149 | // @Success 200 {object} models.Ipxeaccount 150 | // @Failure 404 {object} models.Error "iPXE account not found" 151 | // @Router /ipxeaccount/{username} [delete] 152 | func DeleteIpxeaccount(c *gin.Context) { 153 | db := c.MustGet("db").(*gorm.DB) 154 | username := c.Param("username") 155 | 156 | account := models.Ipxeaccount{ 157 | Username: username, 158 | } 159 | result := db.Delete(&account) 160 | if result.RowsAffected == 0 { 161 | c.AbortWithStatusJSON(http.StatusNotFound, models.Error{ 162 | Error: fmt.Sprintf("iPXE account with username %s not found", username), 163 | }) 164 | return 165 | } 166 | c.JSON(http.StatusOK, struct{}{}) 167 | } 168 | -------------------------------------------------------------------------------- /controllers/computer.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aarnaud/ipxeblue/models" 6 | "github.com/gin-gonic/gin" 7 | "github.com/google/uuid" 8 | "gorm.io/gorm" 9 | "net/http" 10 | "strconv" 11 | ) 12 | 13 | // ListComputers 14 | // @Summary List computers 15 | // @Description List of computers filtered or not 16 | // @Accept json 17 | // @Produce json 18 | // @Param _start query int false "Offset" 19 | // @Success 200 {object} []models.Computer 20 | // @Router /computers [get] 21 | func ListComputers(c *gin.Context) { 22 | db := c.MustGet("db").(*gorm.DB) 23 | 24 | var total int64 25 | 26 | // hack to avoid multiple return of same computer 27 | if _, existe := c.GetQuery("bootentry_uuid"); existe { 28 | // allow search by bootentry 29 | db = db.Joins("Bootorder") 30 | } 31 | // hack to avoid multiple return of same computer without search field 32 | if _, existe := c.GetQuery("tag"); existe { 33 | // allow search by tags 34 | db = db.Joins("Tags") 35 | } 36 | 37 | db = ListFilter(db, c) 38 | db.Model(&models.Computer{}).Count(&total) 39 | c.Header("X-Total-Count", strconv.FormatInt(total, 10)) 40 | 41 | computers := make([]models.Computer, 0) 42 | db = PaginationFilter(db, c) 43 | db.Preload("Tags").Preload("Bootorder", func(db *gorm.DB) *gorm.DB { 44 | return db.Order("bootorders.order ASC") 45 | }).Preload("Bootorder.Bootentry").Find(&computers) 46 | c.JSON(http.StatusOK, computers) 47 | } 48 | 49 | // GetComputer 50 | // @Summary Get computer 51 | // @Description Get a computer by Id 52 | // @Accept json 53 | // @Produce json 54 | // @Param id path string true "Computer UUID" minlength(36) maxlength(36) 55 | // @Success 200 {object} models.Computer 56 | // @Failure 404 {object} models.Error "Computer with uuid %s not found" 57 | // @Router /computers/{id} [get] 58 | func GetComputer(c *gin.Context) { 59 | db := c.MustGet("db").(*gorm.DB) 60 | 61 | id := c.Param("id") 62 | 63 | computer := models.Computer{} 64 | result := db.Preload("Tags").Preload("Bootorder", func(db *gorm.DB) *gorm.DB { 65 | return db.Order("bootorders.order ASC") 66 | }).Preload("Bootorder.Bootentry").Where("uuid = ?", id).First(&computer) 67 | if result.RowsAffected == 0 { 68 | c.AbortWithStatusJSON(http.StatusNotFound, models.Error{ 69 | Error: fmt.Sprintf("Computer with uuid %s not found", id), 70 | }) 71 | return 72 | } 73 | c.JSON(http.StatusOK, computer) 74 | } 75 | 76 | // UpdateComputer 77 | // @Summary Update computer 78 | // @Description Update a computer 79 | // @Accept json 80 | // @Produce json 81 | // @Param id path string true "Computer UUID" minlength(36) maxlength(36) 82 | // @Param computer body models.Computer true "json format computer" 83 | // @Success 200 {object} models.Computer 84 | // @Failure 500 {object} models.Error "Unmarshall error" 85 | // @Failure 400 {object} models.Error "Query ID and UUID miss match" 86 | // @Failure 404 {object} models.Error "Can not find ID" 87 | // @Router /computers/{id} [put] 88 | func UpdateComputer(c *gin.Context) { 89 | db := c.MustGet("db").(*gorm.DB) 90 | id := c.Param("id") 91 | 92 | computerUpdate := models.Computer{} 93 | err := c.Bind(&computerUpdate) 94 | if err != nil { 95 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 96 | Error: err.Error(), 97 | }) 98 | return 99 | } 100 | if computerUpdate.Uuid.String() != id { 101 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "IDs missmatch"}) 102 | return 103 | } 104 | 105 | result := db.Session(&gorm.Session{FullSaveAssociations: true}).Model(&computerUpdate).Updates(map[string]interface{}{ 106 | "Name": computerUpdate.Name, 107 | "Tags": computerUpdate.Tags, 108 | "Bootorder": computerUpdate.Bootorder, 109 | }) 110 | 111 | if result.RowsAffected == 0 { 112 | c.AbortWithStatusJSON(http.StatusNotFound, models.Error{ 113 | Error: fmt.Sprintf("Computer with uuid %s not found", id), 114 | }) 115 | return 116 | } 117 | 118 | if result.Error != nil { 119 | c.AbortWithStatusJSON(http.StatusBadRequest, models.Error{ 120 | Error: fmt.Sprintf("Error to save changes of computer %s", id), 121 | }) 122 | return 123 | } 124 | 125 | // clean tags not present in updated object 126 | computer := models.Computer{} 127 | db.Preload("Tags").Preload("Bootorder").First(&computer, "uuid = ?", computerUpdate.Uuid) 128 | for _, tagInDB := range computer.Tags { 129 | toDelete := true 130 | for _, tagToKeep := range computerUpdate.Tags { 131 | if tagInDB.Key == tagToKeep.Key { 132 | toDelete = false 133 | } 134 | } 135 | if toDelete { 136 | result := db.Delete(tagInDB) 137 | if result.RowsAffected == 0 { 138 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 139 | Error: fmt.Sprintf("failed to delete tag %s for computer %s", tagInDB.Key, id), 140 | }) 141 | return 142 | } 143 | } 144 | } 145 | 146 | // Delete orphan bootorder 147 | for _, bootorderInDB := range computer.Bootorder { 148 | toDelete := true 149 | for _, bootentryToKeep := range computerUpdate.Bootorder { 150 | if bootorderInDB.BootentryUuid.String() == bootentryToKeep.BootentryUuid.String() { 151 | toDelete = false 152 | } 153 | } 154 | if toDelete { 155 | 156 | result = db.Delete(&models.Bootorder{ 157 | ComputerUuid: computer.Uuid, 158 | BootentryUuid: bootorderInDB.BootentryUuid, 159 | }).Where("bootentryUuid = ? AND computerUuid = ?", bootorderInDB.BootentryUuid, computer.Uuid) 160 | if result.RowsAffected == 0 { 161 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 162 | Error: fmt.Sprintf("failed to delete bootorder %s for computer %s", bootorderInDB.BootentryUuid, computer.Uuid), 163 | }) 164 | return 165 | } 166 | } 167 | } 168 | 169 | // refresh data from DB before return it 170 | computer = models.Computer{} 171 | db.Preload("Tags").Preload("Bootorder", func(db *gorm.DB) *gorm.DB { 172 | return db.Order("bootorders.order ASC") 173 | }).Preload("Bootorder.Bootentry").First(&computer, "uuid = ?", computerUpdate.Uuid) 174 | 175 | c.JSON(http.StatusOK, computer) 176 | } 177 | 178 | // DeleteComputer 179 | // @Summary Delete computer 180 | // @Description Delete a computer 181 | // @Accept json 182 | // @Produce json 183 | // @Param id path string true "Computer UUID" minlength(36) maxlength(36) 184 | // @Success 200 185 | // @Failure 400 {object} models.Error "Failed to parse UUID" 186 | // @Failure 404 {object} models.Error "Can not find ID" 187 | // @Router /computers/{id} [delete] 188 | func DeleteComputer(c *gin.Context) { 189 | db := c.MustGet("db").(*gorm.DB) 190 | id := uuid.MustParse(c.Param("id")) 191 | 192 | computer := models.Computer{ 193 | Uuid: id, 194 | } 195 | result := db.Delete(&computer) 196 | if result.RowsAffected == 0 { 197 | c.AbortWithStatusJSON(http.StatusNotFound, models.Error{ 198 | Error: fmt.Sprintf("Computer with uuid %s not found", id), 199 | }) 200 | return 201 | } 202 | c.JSON(http.StatusOK, struct{}{}) 203 | } 204 | -------------------------------------------------------------------------------- /controllers/bootentry.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/aarnaud/ipxeblue/models" 7 | "github.com/aarnaud/ipxeblue/utils" 8 | "github.com/gin-gonic/gin" 9 | "github.com/google/uuid" 10 | "github.com/minio/minio-go/v7" 11 | "github.com/rs/zerolog/log" 12 | "gorm.io/gorm" 13 | "net/http" 14 | "path/filepath" 15 | "strconv" 16 | ) 17 | 18 | // ListBootentries 19 | // @Summary List Bootentries 20 | // @Description List of Bootentry filtered or not 21 | // @Accept json 22 | // @Produce json 23 | // @Param _start query int false "Offset" 24 | // @Success 200 {object} []models.Bootentry 25 | // @Router /bootentries [get] 26 | func ListBootentries(c *gin.Context) { 27 | db := c.MustGet("db").(*gorm.DB) 28 | 29 | var total int64 30 | db = ListFilter(db, c) 31 | db.Model(&models.Bootentry{}).Count(&total) 32 | c.Header("X-Total-Count", strconv.FormatInt(total, 10)) 33 | 34 | bootentries := make([]models.Bootentry, 0) 35 | db = PaginationFilter(db, c) 36 | db.Preload("Files").Find(&bootentries) 37 | c.JSON(http.StatusOK, bootentries) 38 | } 39 | 40 | // GetBootentry 41 | // @Summary Get Bootentry 42 | // @Description Get a Bootentry by Id 43 | // @Accept json 44 | // @Produce json 45 | // @Param id path string true "Bootentry UUID" minlength(36) maxlength(36) 46 | // @Success 200 {object} models.Bootentry 47 | // @Failure 404 {object} models.Error "Computer with uuid %s not found" 48 | // @Router /bootentries/{id} [get] 49 | func GetBootentry(c *gin.Context) { 50 | db := c.MustGet("db").(*gorm.DB) 51 | 52 | id := c.Param("uuid") 53 | 54 | bootentry := models.Bootentry{} 55 | result := db.Preload("Files").Where("uuid = ?", id).First(&bootentry) 56 | if result.RowsAffected == 0 { 57 | c.AbortWithStatusJSON(http.StatusNotFound, models.Error{ 58 | Error: fmt.Sprintf("Bootentry with uuid %s not found", id), 59 | }) 60 | return 61 | } 62 | c.JSON(http.StatusOK, bootentry) 63 | } 64 | 65 | // CreateBootentry 66 | // @Summary Create Bootentry 67 | // @Description Create a Bootentry 68 | // @Accept json 69 | // @Produce json 70 | // @Param bootentry body models.Bootentry true "json format Bootentry" 71 | // @Success 200 {object} models.Bootentry 72 | // @Failure 400 {object} models.Error "Failed to create account in DB" 73 | // @Failure 500 {object} models.Error "Unmarshall error" 74 | // @Router /bootentries [post] 75 | func CreateBootentry(c *gin.Context) { 76 | db := c.MustGet("db").(*gorm.DB) 77 | 78 | bootentryToCreate := models.Bootentry{} 79 | err := c.Bind(&bootentryToCreate) 80 | if err != nil { 81 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 82 | Error: err.Error(), 83 | }) 84 | return 85 | } 86 | 87 | bootentryToCreate.Uuid = uuid.New() 88 | result := db.Create(&bootentryToCreate) 89 | 90 | if result.RowsAffected == 0 { 91 | c.AbortWithStatusJSON(http.StatusBadRequest, models.Error{ 92 | Error: fmt.Sprintf("Bootentry not create, unknown error"), 93 | }) 94 | return 95 | } 96 | 97 | // refresh data from DB before return it 98 | bootentry := models.Bootentry{} 99 | db.First(&bootentry, "uuid = ?", bootentryToCreate.Uuid) 100 | 101 | c.JSON(http.StatusOK, bootentry) 102 | } 103 | 104 | // UpdateBootentry 105 | // @Summary Update Bootentry 106 | // @Description Update a Bootentry 107 | // @Accept json 108 | // @Produce json 109 | // @Param uuid path string true "Bootentry UUID" minlength(36) maxlength(36) 110 | // @Param bootentry body models.Bootentry true "json format of Bootentry" 111 | // @Success 200 {object} models.Bootentry 112 | // @Failure 500 {object} models.Error "Unmarshall error" 113 | // @Failure 400 {object} models.Error "Query uuid and uuid miss match" 114 | // @Failure 404 {object} models.Error "Bootentry UUID not found" 115 | // @Router /bootentries/{username} [put] 116 | func UpdateBootentry(c *gin.Context) { 117 | db := c.MustGet("db").(*gorm.DB) 118 | config := c.MustGet("config").(*utils.Config) 119 | filestore := c.MustGet("filestore").(*minio.Client) 120 | id := c.Param("uuid") 121 | 122 | bootentryUpdate := models.Bootentry{} 123 | err := c.Bind(&bootentryUpdate) 124 | if err != nil { 125 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 126 | Error: err.Error(), 127 | }) 128 | return 129 | } 130 | 131 | if bootentryUpdate.Uuid.String() != id { 132 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Usernames missmatch"}) 133 | return 134 | } 135 | 136 | for _, file := range bootentryUpdate.Files { 137 | if *file.Templatized && !*file.Protected { 138 | c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "templatized file has to be protected"}) 139 | return 140 | } 141 | } 142 | 143 | result := db.Model(&bootentryUpdate).Preload("Files").Updates(bootentryUpdate) 144 | if result.RowsAffected == 0 { 145 | c.AbortWithStatusJSON(http.StatusNotFound, models.Error{ 146 | Error: fmt.Sprintf("Bootentry with uuid %s not found", id), 147 | }) 148 | return 149 | } 150 | for _, file := range bootentryUpdate.Files { 151 | result := db.Model(&file).Updates(file) 152 | if result.RowsAffected != 1 { 153 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 154 | Error: fmt.Sprintf("Failed to save file, %v", result.Error), 155 | }) 156 | return 157 | } 158 | } 159 | 160 | // clean files not present in updated object 161 | bootenty := models.Bootentry{} 162 | db.Preload("Files").First(&bootenty, "uuid = ?", bootentryUpdate.Uuid) 163 | for _, fileInDB := range bootenty.Files { 164 | toDelete := true 165 | for _, fileToKeep := range bootentryUpdate.Files { 166 | if fileInDB.Name == fileToKeep.Name { 167 | toDelete = false 168 | } 169 | } 170 | if toDelete { 171 | if err = filestore.RemoveObject( 172 | context.Background(), config.MinioConfig.BucketName, 173 | fileInDB.GetFileStorePath(), minio.RemoveObjectOptions{}); err != nil { 174 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 175 | Error: fmt.Sprintf("failed to delete object file %s, %v", fileInDB.GetFileStorePath(), err), 176 | }) 177 | return 178 | } 179 | result := db.Delete(fileInDB) 180 | if result.RowsAffected == 0 { 181 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 182 | Error: fmt.Sprintf("failed to delete file %s for bootentry %s, %v", fileInDB.Name, id, err), 183 | }) 184 | return 185 | } 186 | } 187 | } 188 | 189 | // refresh data from DB before return it 190 | bootentry := models.Bootentry{} 191 | db.Preload("Files").First(&bootentry, "uuid = ?", bootentryUpdate.Uuid) 192 | 193 | c.JSON(http.StatusOK, bootentry) 194 | } 195 | 196 | // DeleteBootentry 197 | // @Summary Delete Bootentry 198 | // @Description Delete Bootentry 199 | // @Accept json 200 | // @Produce json 201 | // @Param uuid path string true "Bootentry UUID" minlength(36) maxlength(36) 202 | // @Success 200 203 | // @Failure 400 {object} models.Error "Failed to parse UUID" 204 | // @Failure 404 {object} models.Error "Bootentry UUID not found" 205 | // @Router /Bootentries/{username} [delete] 206 | func DeleteBootentry(c *gin.Context) { 207 | db := c.MustGet("db").(*gorm.DB) 208 | config := c.MustGet("config").(*utils.Config) 209 | filestore := c.MustGet("filestore").(*minio.Client) 210 | id := uuid.MustParse(c.Param("uuid")) 211 | 212 | err := utils.RemoveRecursive(filestore, config.MinioConfig.BucketName, id.String()) 213 | if err != nil { 214 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 215 | Error: fmt.Sprintf("failed to delete object file %s, %v", id.String(), err), 216 | }) 217 | return 218 | } 219 | 220 | bootentry := models.Bootentry{ 221 | Uuid: id, 222 | } 223 | result := db.Preload("Files").Delete(&bootentry) 224 | if result.RowsAffected == 0 { 225 | c.AbortWithStatusJSON(http.StatusNotFound, models.Error{ 226 | Error: fmt.Sprintf("Bootentry with uuid %s not found", id), 227 | }) 228 | return 229 | } 230 | c.JSON(http.StatusOK, struct{}{}) 231 | } 232 | 233 | func UploadBootentryFile(c *gin.Context) { 234 | filestore := c.MustGet("filestore").(*minio.Client) 235 | config := c.MustGet("config").(*utils.Config) 236 | id := uuid.MustParse(c.Param("uuid")) 237 | 238 | file, err := c.FormFile("file") 239 | if err != nil { 240 | log.Error().Err(err).Msg("failed to read form") 241 | c.AbortWithStatusJSON(http.StatusBadRequest, models.Error{ 242 | Error: fmt.Sprintf("get form err: %s", err.Error()), 243 | }) 244 | return 245 | } 246 | 247 | filename := filepath.Base(file.Filename) 248 | bootentryfile := models.BootentryFile{ 249 | Name: filename, 250 | BootentryUUID: id, 251 | } 252 | filereader, err := file.Open() 253 | if err != nil { 254 | log.Error().Err(err).Msg("failed to open file in form") 255 | c.AbortWithStatusJSON(http.StatusBadRequest, models.Error{ 256 | Error: fmt.Sprintf("open file err: %s", err.Error()), 257 | }) 258 | return 259 | } 260 | _, err = filestore.PutObject(context.Background(), config.MinioConfig.BucketName, bootentryfile.GetFileStorePath(), 261 | filereader, file.Size, minio.PutObjectOptions{}) 262 | if err != nil { 263 | log.Error().Err(err).Msg("failed to upload file to storage backend") 264 | c.AbortWithStatusJSON(http.StatusBadRequest, models.Error{ 265 | Error: fmt.Sprintf("upload file err: %s", err.Error()), 266 | }) 267 | return 268 | } 269 | 270 | c.JSON(http.StatusAccepted, struct{}{}) 271 | } 272 | 273 | func DownloadBootentryFile(c *gin.Context) { 274 | db := c.MustGet("db").(*gorm.DB) 275 | id := uuid.MustParse(c.Param("uuid")) 276 | name := c.Param("name") 277 | id, err := uuid.Parse(c.Param("uuid")) 278 | if err != nil { 279 | c.AbortWithStatusJSON(http.StatusBadRequest, models.Error{ 280 | Error: err.Error(), 281 | }) 282 | return 283 | } 284 | 285 | bootentryFile := models.BootentryFile{ 286 | Name: name, 287 | BootentryUUID: id, 288 | } 289 | result := db.Model(&models.BootentryFile{}).Where("bootentry_uuid = ? AND name = ?", id, name).First(&bootentryFile) 290 | if result.RowsAffected == 0 { 291 | c.AbortWithStatusJSON(http.StatusNotFound, models.Error{ 292 | Error: fmt.Sprintf("Bootentry with uuid %s not found", id), 293 | }) 294 | return 295 | } 296 | // disable template when download from API 297 | falseRef := false 298 | bootentryFile.Templatized = &falseRef 299 | Downloadfile(c, &bootentryFile, nil) 300 | } 301 | -------------------------------------------------------------------------------- /controllers/ipxescript.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "github.com/aarnaud/ipxeblue/models" 8 | "github.com/aarnaud/ipxeblue/utils" 9 | "github.com/gin-gonic/gin" 10 | "github.com/google/uuid" 11 | "github.com/jackc/pgtype" 12 | "github.com/minio/minio-go/v7" 13 | "gorm.io/gorm" 14 | "io" 15 | "net/http" 16 | "path" 17 | "strconv" 18 | "strings" 19 | "text/template" 20 | "time" 21 | ) 22 | 23 | func updateOrCreateComputer(c *gin.Context, id uuid.UUID, mac pgtype.Macaddr, ip pgtype.Inet) models.Computer { 24 | config := c.MustGet("config").(*utils.Config) 25 | var computer models.Computer 26 | var err error 27 | db := c.MustGet("db").(*gorm.DB) 28 | 29 | /* 30 | a = asset 31 | m = manufacturer 32 | p = product 33 | f = family 34 | sn = serial 35 | uuid = uuid 36 | c = cpu_arch 37 | t = platform 38 | h = hostname 39 | v = version 40 | */ 41 | 42 | // auto set name based on hostname or asset for new computer 43 | name := c.DefaultQuery("hostname", "") 44 | if name == "" { 45 | name = c.DefaultQuery("asset", c.DefaultQuery("a", "")) 46 | } 47 | if name == "" { 48 | name = c.DefaultQuery("serial", c.DefaultQuery("sn", "")) 49 | } 50 | 51 | computer, err = searchComputer(db, id, mac) 52 | var accountID *string 53 | if value, ok := c.Get("account"); ok { 54 | accountID = &value.(*models.Ipxeaccount).Username 55 | } 56 | 57 | if err != nil { 58 | // Default bootentry for new computer 59 | bootorder := make([]*models.Bootorder, 0) 60 | if config.DefaultBootentryName != "" { 61 | defaultBootentry := &models.Bootentry{} 62 | result := db.Where("name = ?", config.DefaultBootentryName).Find(&defaultBootentry) 63 | if result.RowsAffected != 0 { 64 | bootorder = append(bootorder, &models.Bootorder{ 65 | BootentryUuid: defaultBootentry.Uuid, 66 | }) 67 | } 68 | } 69 | computer = models.Computer{ 70 | Name: name, 71 | Asset: c.DefaultQuery("asset", c.DefaultQuery("a", "")), 72 | BuildArch: c.DefaultQuery("buildarch", c.DefaultQuery("c", "")), 73 | Hostname: c.DefaultQuery("hostname", c.DefaultQuery("h", "")), 74 | LastSeen: time.Now(), 75 | Mac: mac, 76 | IP: ip, 77 | Manufacturer: c.DefaultQuery("manufacturer", c.DefaultQuery("m", "")), 78 | Platform: c.DefaultQuery("platform", c.DefaultQuery("t", "")), 79 | Product: c.DefaultQuery("product", c.DefaultQuery("p", "")), 80 | Serial: c.DefaultQuery("serial", c.DefaultQuery("sn", "")), 81 | Uuid: id, 82 | Version: c.DefaultQuery("version", c.DefaultQuery("v", "")), 83 | LastIpxeaccountID: accountID, 84 | Bootorder: bootorder, 85 | } 86 | db.FirstOrCreate(&computer) 87 | } 88 | 89 | // Uuid may change with Virtual Machine like VMware 90 | if computer.Uuid != id { 91 | db.Model(&computer).Where("uuid = ?", computer.Uuid).Update("uuid", id) 92 | } 93 | 94 | if time.Now().Sub(computer.LastSeen).Seconds() > 10 { 95 | computer.Asset = c.DefaultQuery("asset", c.DefaultQuery("a", "")) 96 | computer.BuildArch = c.DefaultQuery("buildarch", c.DefaultQuery("c", "")) 97 | computer.Hostname = c.DefaultQuery("hostname", "") 98 | computer.LastSeen = time.Now() 99 | computer.Mac = mac 100 | computer.IP = ip 101 | computer.Manufacturer = c.DefaultQuery("manufacturer", c.DefaultQuery("m", "")) 102 | computer.Platform = c.DefaultQuery("platform", c.DefaultQuery("t", "")) 103 | computer.Product = c.DefaultQuery("product", c.DefaultQuery("p", "")) 104 | computer.Serial = c.DefaultQuery("serial", c.DefaultQuery("sn", "")) 105 | computer.Version = c.DefaultQuery("version", c.DefaultQuery("v", "")) 106 | computer.LastIpxeaccountID = accountID 107 | db.Save(computer) 108 | } 109 | 110 | return computer 111 | } 112 | 113 | func searchComputer(db *gorm.DB, id uuid.UUID, mac pgtype.Macaddr) (models.Computer, error) { 114 | computer := models.Computer{} 115 | result := db.Preload("Tags").Where("uuid = ?", id).First(&computer) 116 | if result.RowsAffected > 0 { 117 | return computer, nil 118 | } 119 | result = db.Preload("Tags").Where("mac = ?", mac.Addr.String()).First(&computer) 120 | if result.RowsAffected > 0 { 121 | return computer, nil 122 | } 123 | return computer, fmt.Errorf("computer not found") 124 | } 125 | 126 | func IpxeScript(c *gin.Context) { 127 | config := c.MustGet("config").(*utils.Config) 128 | 129 | // redirect to admin if it's a browser 130 | if strings.Contains(c.Request.Header.Get("Accept"), "text/html") { 131 | c.Redirect(http.StatusMultipleChoices, "/admin/") 132 | return 133 | } 134 | 135 | // basic check or reply with ipxe chain 136 | _, uuidExist := c.GetQuery("uuid") 137 | _, macExist := c.GetQuery("mac") 138 | _, ipExist := c.GetQuery("ip") 139 | if !uuidExist || !macExist || !ipExist { 140 | baseURL := *config.BaseURL 141 | // use the same scheme from request to generate URL 142 | if schem := c.Request.Header.Get("X-Forwarded-Proto"); schem != "" { 143 | baseURL.Scheme = schem 144 | } 145 | c.HTML(http.StatusOK, "index.gohtml", gin.H{ 146 | "BaseURL": config.BaseURL.String(), 147 | "Scheme": config.BaseURL.Scheme, 148 | "Host": config.BaseURL.Host, 149 | }) 150 | return 151 | } 152 | 153 | // process query params 154 | db := c.MustGet("db").(*gorm.DB) 155 | id, err := uuid.Parse(c.Query("uuid")) 156 | if err != nil { 157 | c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{ 158 | "error": err.Error(), 159 | }) 160 | return 161 | } 162 | 163 | mac := pgtype.Macaddr{} 164 | err = mac.DecodeText(nil, []byte(c.Query("mac"))) 165 | if err != nil { 166 | c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{ 167 | "error": err.Error(), 168 | }) 169 | return 170 | } 171 | 172 | ip := pgtype.Inet{} 173 | err = ip.DecodeText(nil, []byte(c.Query("ip"))) 174 | if err != nil { 175 | c.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{ 176 | "error": err.Error(), 177 | }) 178 | return 179 | } 180 | 181 | computer := updateOrCreateComputer(c, id, mac, ip) 182 | // Add computer in gin context to use it in template function 183 | c.Set("computer", &computer) 184 | 185 | c.Header("Content-Type", "text/plain; charset=utf-8") 186 | bootorder := models.Bootorder{} 187 | result := db.Preload("Bootentry").Preload("Bootentry.Files"). 188 | Where("computer_uuid = ?", computer.Uuid).Order("bootorders.order").First(&bootorder) 189 | if result.RowsAffected == 0 { 190 | c.HTML(http.StatusOK, "empty.gohtml", gin.H{}) 191 | return 192 | } 193 | bootentry := bootorder.Bootentry 194 | 195 | // Create template name by the uuid 196 | tpl := template.New(bootentry.Uuid.String()) 197 | // provide a func in the FuncMap which can access tpl to be able to look up templates 198 | tpl.Funcs(utils.GetCustomFunctions(c, tpl)) 199 | 200 | tpl, err = tpl.Parse(bootentry.IpxeScript) 201 | if err != nil { 202 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ 203 | "error": err.Error(), 204 | }) 205 | return 206 | } 207 | 208 | writer := bytes.NewBuffer([]byte{}) 209 | writer.Write([]byte("#!ipxe\n")) 210 | writer.Write([]byte(fmt.Sprintf("echo Booting %s\n", bootentry.Description))) 211 | 212 | // if bootentry selected is menu load all bootentries as template 213 | if bootentry.Name == "menu" { 214 | bootentries := make([]models.Bootentry, 0) 215 | db.Preload("Files").Where("name != 'menu'").Find(&bootentries) 216 | for _, be := range bootentries { 217 | tpl.New(be.Uuid.String()).Parse(be.IpxeScript) 218 | } 219 | err = tpl.ExecuteTemplate(writer, bootentry.Uuid.String(), gin.H{ 220 | "Computer": computer, 221 | "Bootentry": bootentry, 222 | "Bootentries": bootentries, 223 | }) 224 | if err != nil { 225 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ 226 | "error": err.Error(), 227 | }) 228 | return 229 | } 230 | } else { 231 | err = tpl.ExecuteTemplate(writer, bootentry.Uuid.String(), bootentry) 232 | if err != nil { 233 | c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ 234 | "error": err.Error(), 235 | }) 236 | return 237 | } 238 | // add failed goto that can be use in ipxescript 239 | writer.Write([]byte("\n\n:failed\necho Booting failed, waiting 10 sec\nsleep 10\nexit 1")) 240 | } 241 | 242 | // reset bootentry if not persistent 243 | if !*bootentry.Persistent { 244 | db.Model(&bootorder).Delete(&bootorder) 245 | } 246 | 247 | c.Data(http.StatusOK, "text/plain", writer.Bytes()) 248 | } 249 | 250 | func DownloadPublicFile(c *gin.Context) { 251 | db := c.MustGet("db").(*gorm.DB) 252 | filepath := c.Param("filepath") 253 | filename := path.Base(filepath) 254 | subpath := strings.TrimLeft(path.Dir(filepath), "/") 255 | id, err := uuid.Parse(c.Param("uuid")) 256 | if err != nil { 257 | c.AbortWithStatusJSON(http.StatusBadRequest, models.Error{ 258 | Error: err.Error(), 259 | }) 260 | return 261 | } 262 | 263 | bootentryFile := models.BootentryFile{ 264 | Name: filename, 265 | SubPath: subpath, 266 | BootentryUUID: id, 267 | } 268 | 269 | db.Model(&models.BootentryFile{}).Where("bootentry_uuid = ? AND name = ?", id, filename).First(&bootentryFile) 270 | 271 | if *bootentryFile.Protected { 272 | c.AbortWithStatusJSON(http.StatusForbidden, models.Error{ 273 | Error: fmt.Sprintf("protected file, you need to use a token URL"), 274 | }) 275 | return 276 | } 277 | 278 | Downloadfile(c, &bootentryFile, nil) 279 | 280 | } 281 | 282 | func DownloadProtectedFile(c *gin.Context) { 283 | db := c.MustGet("db").(*gorm.DB) 284 | filepath := c.Param("filepath") 285 | filename := path.Base(filepath) 286 | subpath := strings.TrimLeft(path.Dir(filepath), "/") 287 | tokenString := c.Param("token") 288 | token := models.Token{} 289 | 290 | db.Preload("Computer"). 291 | Preload("Computer.Tags"). 292 | Preload("Bootentry"). 293 | Preload("Bootentry.Files"). 294 | Where("token = ?", tokenString).First(&token) 295 | 296 | if token.BootentryFile != nil { 297 | if *token.Filename != filename { 298 | c.AbortWithStatusJSON(http.StatusBadRequest, models.Error{ 299 | Error: fmt.Sprintf("filename not allow with this token"), 300 | }) 301 | return 302 | } 303 | } else { 304 | for _, file := range token.Bootentry.Files { 305 | if file.Name == filename && file.SubPath == subpath { 306 | token.BootentryFile = &file 307 | token.BootentryFile.Bootentry = &token.Bootentry 308 | c.Set("computer", &token.Computer) 309 | break 310 | } 311 | } 312 | // if BootentryFile still null, return not found 313 | if token.BootentryFile == nil { 314 | c.AbortWithStatusJSON(http.StatusNotFound, models.Error{ 315 | Error: fmt.Sprintf("file not found"), 316 | }) 317 | return 318 | } 319 | } 320 | 321 | Downloadfile(c, token.BootentryFile, &token.Computer) 322 | } 323 | 324 | func Downloadfile(c *gin.Context, bootentryFile *models.BootentryFile, computer *models.Computer) { 325 | filestore := c.MustGet("filestore").(*minio.Client) 326 | config := c.MustGet("config").(*utils.Config) 327 | 328 | getObjectOptions := minio.GetObjectOptions{} 329 | if byterange := c.Request.Header.Get("Range"); byterange != "" { 330 | rangesplit := strings.Split(byterange, "=") 331 | rangevalue := strings.Split(rangesplit[1], "-") 332 | start, err := strconv.ParseInt(rangevalue[0], 10, 64) 333 | if err != nil { 334 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 335 | Error: err.Error(), 336 | }) 337 | return 338 | } 339 | end, err := strconv.ParseInt(rangevalue[1], 10, 64) 340 | if err != nil { 341 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 342 | Error: err.Error(), 343 | }) 344 | return 345 | } 346 | err = getObjectOptions.SetRange(start, end) 347 | if err != nil { 348 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 349 | Error: err.Error(), 350 | }) 351 | return 352 | } 353 | } 354 | 355 | reader, objectFile, headers, err := minio.Core{filestore}.GetObject(context.Background(), 356 | config.MinioConfig.BucketName, bootentryFile.GetFileStorePath(), getObjectOptions) 357 | if err != nil { 358 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 359 | Error: err.Error(), 360 | }) 361 | return 362 | } 363 | 364 | isTemplate := *bootentryFile.Templatized && objectFile.Size < 2*1024*1024 365 | 366 | for header, value := range headers { 367 | if header == "Content-Length" && isTemplate { 368 | continue 369 | } 370 | c.Header(header, value[0]) 371 | } 372 | 373 | if c.Request.Method == "HEAD" { 374 | c.Done() 375 | return 376 | } 377 | 378 | if isTemplate { 379 | // Create template name by the uuid 380 | tpl := template.New(bootentryFile.Name) 381 | // provide a func in the FuncMap which can access tpl to be able to look up templates 382 | tpl.Funcs(utils.GetCustomFunctions(c, tpl)) 383 | buf := new(strings.Builder) 384 | _, err := io.Copy(buf, reader) 385 | if err != nil { 386 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 387 | Error: err.Error(), 388 | }) 389 | return 390 | } 391 | tpl, err = tpl.Parse(buf.String()) 392 | writer := bytes.NewBuffer([]byte{}) 393 | err = tpl.ExecuteTemplate(writer, bootentryFile.Name, gin.H{ 394 | "Bootentry": bootentryFile.Bootentry, 395 | "Computer": computer, 396 | }) 397 | if err != nil { 398 | c.AbortWithStatusJSON(http.StatusInternalServerError, models.Error{ 399 | Error: err.Error(), 400 | }) 401 | } 402 | // override Content-Length with size after template is render 403 | c.Header("Content-Length", fmt.Sprintf("%d", writer.Len())) 404 | c.Data(http.StatusOK, objectFile.ContentType, writer.Bytes()) 405 | } else { 406 | c.DataFromReader(http.StatusOK, objectFile.Size, objectFile.ContentType, reader, nil) 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /docs/swagger.yaml: -------------------------------------------------------------------------------- 1 | basePath: /api/v1 2 | definitions: 3 | models.Bootentry: 4 | properties: 5 | created_at: 6 | type: string 7 | description: 8 | type: string 9 | files: 10 | items: 11 | $ref: '#/definitions/models.BootentryFile' 12 | type: array 13 | id: 14 | type: string 15 | ipxe_script: 16 | type: string 17 | name: 18 | type: string 19 | persistent: 20 | type: boolean 21 | updated_at: 22 | type: string 23 | type: object 24 | models.BootentryFile: 25 | properties: 26 | bootentry: 27 | $ref: '#/definitions/models.Bootentry' 28 | name: 29 | type: string 30 | protected: 31 | type: boolean 32 | subpath: 33 | type: string 34 | templatized: 35 | type: boolean 36 | type: object 37 | models.Computer: 38 | properties: 39 | asset: 40 | type: string 41 | bootentry: 42 | $ref: '#/definitions/models.Bootentry' 43 | bootentry_uuid: 44 | type: string 45 | build_arch: 46 | type: string 47 | created_at: 48 | type: string 49 | hostname: 50 | type: string 51 | id: 52 | type: string 53 | last_ipxeaccount: 54 | type: string 55 | last_seen: 56 | type: string 57 | manufacturer: 58 | type: string 59 | name: 60 | type: string 61 | platform: 62 | type: string 63 | product: 64 | type: string 65 | serial: 66 | type: string 67 | tags: 68 | items: 69 | $ref: '#/definitions/models.Tag' 70 | type: array 71 | updated_at: 72 | type: string 73 | version: 74 | type: string 75 | type: object 76 | models.Error: 77 | properties: 78 | error: 79 | type: string 80 | type: object 81 | models.Ipxeaccount: 82 | properties: 83 | created_at: 84 | type: string 85 | is_admin: 86 | type: boolean 87 | last_login: 88 | type: string 89 | password: 90 | type: string 91 | updated_at: 92 | type: string 93 | username: 94 | type: string 95 | type: object 96 | models.Tag: 97 | properties: 98 | key: 99 | type: string 100 | value: 101 | type: string 102 | type: object 103 | host: localhost:8080 104 | info: 105 | contact: {} 106 | description: Manage PXE boot 107 | license: 108 | name: Apache 2.0 109 | url: http://www.apache.org/licenses/LICENSE-2.0.html 110 | termsOfService: http://swagger.io/terms/ 111 | title: ipxeblue API 112 | version: "0.1" 113 | paths: 114 | /Bootentries/{username}: 115 | delete: 116 | consumes: 117 | - application/json 118 | description: Delete Bootentry 119 | parameters: 120 | - description: Bootentry UUID 121 | in: path 122 | maxLength: 36 123 | minLength: 36 124 | name: uuid 125 | required: true 126 | type: string 127 | produces: 128 | - application/json 129 | responses: 130 | "200": 131 | description: "" 132 | "400": 133 | description: Failed to parse UUID 134 | schema: 135 | $ref: '#/definitions/models.Error' 136 | "404": 137 | description: Bootentry UUID not found 138 | schema: 139 | $ref: '#/definitions/models.Error' 140 | summary: Delete Bootentry 141 | /bootentries: 142 | get: 143 | consumes: 144 | - application/json 145 | description: List of Bootentry filtered or not 146 | parameters: 147 | - description: Offset 148 | in: query 149 | name: _start 150 | type: integer 151 | produces: 152 | - application/json 153 | responses: 154 | "200": 155 | description: OK 156 | schema: 157 | items: 158 | $ref: '#/definitions/models.Bootentry' 159 | type: array 160 | summary: List Bootentries 161 | post: 162 | consumes: 163 | - application/json 164 | description: Create a Bootentry 165 | parameters: 166 | - description: json format Bootentry 167 | in: body 168 | name: bootentry 169 | required: true 170 | schema: 171 | $ref: '#/definitions/models.Bootentry' 172 | produces: 173 | - application/json 174 | responses: 175 | "200": 176 | description: OK 177 | schema: 178 | $ref: '#/definitions/models.Bootentry' 179 | "400": 180 | description: Failed to create account in DB 181 | schema: 182 | $ref: '#/definitions/models.Error' 183 | "500": 184 | description: Unmarshall error 185 | schema: 186 | $ref: '#/definitions/models.Error' 187 | summary: Create Bootentry 188 | /bootentries/{id}: 189 | get: 190 | consumes: 191 | - application/json 192 | description: Get a Bootentry by Id 193 | parameters: 194 | - description: Bootentry UUID 195 | in: path 196 | maxLength: 36 197 | minLength: 36 198 | name: id 199 | required: true 200 | type: string 201 | produces: 202 | - application/json 203 | responses: 204 | "200": 205 | description: OK 206 | schema: 207 | $ref: '#/definitions/models.Bootentry' 208 | "404": 209 | description: Computer with uuid %s not found 210 | schema: 211 | $ref: '#/definitions/models.Error' 212 | summary: Get Bootentry 213 | /bootentries/{username}: 214 | put: 215 | consumes: 216 | - application/json 217 | description: Update a Bootentry 218 | parameters: 219 | - description: Bootentry UUID 220 | in: path 221 | maxLength: 36 222 | minLength: 36 223 | name: uuid 224 | required: true 225 | type: string 226 | - description: json format of Bootentry 227 | in: body 228 | name: bootentry 229 | required: true 230 | schema: 231 | $ref: '#/definitions/models.Bootentry' 232 | produces: 233 | - application/json 234 | responses: 235 | "200": 236 | description: OK 237 | schema: 238 | $ref: '#/definitions/models.Bootentry' 239 | "400": 240 | description: Query uuid and uuid miss match 241 | schema: 242 | $ref: '#/definitions/models.Error' 243 | "404": 244 | description: Bootentry UUID not found 245 | schema: 246 | $ref: '#/definitions/models.Error' 247 | "500": 248 | description: Unmarshall error 249 | schema: 250 | $ref: '#/definitions/models.Error' 251 | summary: Update Bootentry 252 | /computers: 253 | get: 254 | consumes: 255 | - application/json 256 | description: List of computers filtered or not 257 | parameters: 258 | - description: Offset 259 | in: query 260 | name: _start 261 | type: integer 262 | produces: 263 | - application/json 264 | responses: 265 | "200": 266 | description: OK 267 | schema: 268 | items: 269 | $ref: '#/definitions/models.Computer' 270 | type: array 271 | summary: List computers 272 | /computers/{id}: 273 | delete: 274 | consumes: 275 | - application/json 276 | description: Delete a computer 277 | parameters: 278 | - description: Computer UUID 279 | in: path 280 | maxLength: 36 281 | minLength: 36 282 | name: id 283 | required: true 284 | type: string 285 | produces: 286 | - application/json 287 | responses: 288 | "200": 289 | description: "" 290 | "400": 291 | description: Failed to parse UUID 292 | schema: 293 | $ref: '#/definitions/models.Error' 294 | "404": 295 | description: Can not find ID 296 | schema: 297 | $ref: '#/definitions/models.Error' 298 | summary: Delete computer 299 | get: 300 | consumes: 301 | - application/json 302 | description: Get a computer by Id 303 | parameters: 304 | - description: Computer UUID 305 | in: path 306 | maxLength: 36 307 | minLength: 36 308 | name: id 309 | required: true 310 | type: string 311 | produces: 312 | - application/json 313 | responses: 314 | "200": 315 | description: OK 316 | schema: 317 | $ref: '#/definitions/models.Computer' 318 | "404": 319 | description: Computer with uuid %s not found 320 | schema: 321 | $ref: '#/definitions/models.Error' 322 | summary: Get computer 323 | put: 324 | consumes: 325 | - application/json 326 | description: Update a computer 327 | parameters: 328 | - description: Computer UUID 329 | in: path 330 | maxLength: 36 331 | minLength: 36 332 | name: id 333 | required: true 334 | type: string 335 | - description: json format computer 336 | in: body 337 | name: computer 338 | required: true 339 | schema: 340 | $ref: '#/definitions/models.Computer' 341 | produces: 342 | - application/json 343 | responses: 344 | "200": 345 | description: OK 346 | schema: 347 | $ref: '#/definitions/models.Computer' 348 | "400": 349 | description: Query ID and UUID miss match 350 | schema: 351 | $ref: '#/definitions/models.Error' 352 | "404": 353 | description: Can not find ID 354 | schema: 355 | $ref: '#/definitions/models.Error' 356 | "500": 357 | description: Unmarshall error 358 | schema: 359 | $ref: '#/definitions/models.Error' 360 | summary: Update computer 361 | /ipxeaccount: 362 | get: 363 | consumes: 364 | - application/json 365 | description: List of accounts for ipxe 366 | parameters: 367 | - description: Offset 368 | in: query 369 | name: _start 370 | type: integer 371 | produces: 372 | - application/json 373 | responses: 374 | "200": 375 | description: OK 376 | schema: 377 | items: 378 | $ref: '#/definitions/models.Ipxeaccount' 379 | type: array 380 | summary: List iPXE account 381 | post: 382 | consumes: 383 | - application/json 384 | description: Create a iPXE account 385 | parameters: 386 | - description: json format iPXE account 387 | in: body 388 | name: ipxeaccount 389 | required: true 390 | schema: 391 | $ref: '#/definitions/models.Ipxeaccount' 392 | produces: 393 | - application/json 394 | responses: 395 | "200": 396 | description: OK 397 | schema: 398 | $ref: '#/definitions/models.Ipxeaccount' 399 | "400": 400 | description: Failed to create account in DB 401 | schema: 402 | $ref: '#/definitions/models.Error' 403 | "500": 404 | description: Unmarshall error 405 | schema: 406 | $ref: '#/definitions/models.Error' 407 | summary: Create iPXE account 408 | /ipxeaccount/{username}: 409 | delete: 410 | consumes: 411 | - application/json 412 | description: Delete a iPXE account 413 | parameters: 414 | - description: Username 415 | in: path 416 | name: username 417 | required: true 418 | type: string 419 | produces: 420 | - application/json 421 | responses: 422 | "200": 423 | description: OK 424 | schema: 425 | $ref: '#/definitions/models.Ipxeaccount' 426 | "404": 427 | description: iPXE account not found 428 | schema: 429 | $ref: '#/definitions/models.Error' 430 | summary: Delete iPXE account 431 | get: 432 | consumes: 433 | - application/json 434 | description: Get iPXE account by username 435 | parameters: 436 | - description: Username 437 | in: path 438 | name: username 439 | required: true 440 | type: string 441 | produces: 442 | - application/json 443 | responses: 444 | "200": 445 | description: OK 446 | schema: 447 | $ref: '#/definitions/models.Ipxeaccount' 448 | "404": 449 | description: iPXE account not found 450 | schema: 451 | $ref: '#/definitions/models.Error' 452 | summary: Get iPXE account 453 | put: 454 | consumes: 455 | - application/json 456 | description: Update a iPXE account 457 | parameters: 458 | - description: Username 459 | in: path 460 | name: username 461 | required: true 462 | type: string 463 | - description: json format iPXE account 464 | in: body 465 | name: ipxeaccount 466 | required: true 467 | schema: 468 | $ref: '#/definitions/models.Ipxeaccount' 469 | produces: 470 | - application/json 471 | responses: 472 | "200": 473 | description: OK 474 | schema: 475 | $ref: '#/definitions/models.Ipxeaccount' 476 | "400": 477 | description: Query username and username miss match 478 | schema: 479 | $ref: '#/definitions/models.Error' 480 | "404": 481 | description: iPXE account not found 482 | schema: 483 | $ref: '#/definitions/models.Error' 484 | "500": 485 | description: Unmarshall error 486 | schema: 487 | $ref: '#/definitions/models.Error' 488 | summary: Update iPXE account 489 | swagger: "2.0" 490 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. -------------------------------------------------------------------------------- /docs/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "description": "Manage PXE boot", 5 | "title": "ipxeblue API", 6 | "termsOfService": "http://swagger.io/terms/", 7 | "contact": {}, 8 | "license": { 9 | "name": "Apache 2.0", 10 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 11 | }, 12 | "version": "0.1" 13 | }, 14 | "host": "localhost:8080", 15 | "basePath": "/api/v1", 16 | "paths": { 17 | "/Bootentries/{username}": { 18 | "delete": { 19 | "description": "Delete Bootentry", 20 | "consumes": [ 21 | "application/json" 22 | ], 23 | "produces": [ 24 | "application/json" 25 | ], 26 | "summary": "Delete Bootentry", 27 | "parameters": [ 28 | { 29 | "maxLength": 36, 30 | "minLength": 36, 31 | "type": "string", 32 | "description": "Bootentry UUID", 33 | "name": "uuid", 34 | "in": "path", 35 | "required": true 36 | } 37 | ], 38 | "responses": { 39 | "200": { 40 | "description": "" 41 | }, 42 | "400": { 43 | "description": "Failed to parse UUID", 44 | "schema": { 45 | "$ref": "#/definitions/models.Error" 46 | } 47 | }, 48 | "404": { 49 | "description": "Bootentry UUID not found", 50 | "schema": { 51 | "$ref": "#/definitions/models.Error" 52 | } 53 | } 54 | } 55 | } 56 | }, 57 | "/bootentries": { 58 | "get": { 59 | "description": "List of Bootentry filtered or not", 60 | "consumes": [ 61 | "application/json" 62 | ], 63 | "produces": [ 64 | "application/json" 65 | ], 66 | "summary": "List Bootentries", 67 | "parameters": [ 68 | { 69 | "type": "integer", 70 | "description": "Offset", 71 | "name": "_start", 72 | "in": "query" 73 | } 74 | ], 75 | "responses": { 76 | "200": { 77 | "description": "OK", 78 | "schema": { 79 | "type": "array", 80 | "items": { 81 | "$ref": "#/definitions/models.Bootentry" 82 | } 83 | } 84 | } 85 | } 86 | }, 87 | "post": { 88 | "description": "Create a Bootentry", 89 | "consumes": [ 90 | "application/json" 91 | ], 92 | "produces": [ 93 | "application/json" 94 | ], 95 | "summary": "Create Bootentry", 96 | "parameters": [ 97 | { 98 | "description": "json format Bootentry", 99 | "name": "bootentry", 100 | "in": "body", 101 | "required": true, 102 | "schema": { 103 | "$ref": "#/definitions/models.Bootentry" 104 | } 105 | } 106 | ], 107 | "responses": { 108 | "200": { 109 | "description": "OK", 110 | "schema": { 111 | "$ref": "#/definitions/models.Bootentry" 112 | } 113 | }, 114 | "400": { 115 | "description": "Failed to create account in DB", 116 | "schema": { 117 | "$ref": "#/definitions/models.Error" 118 | } 119 | }, 120 | "500": { 121 | "description": "Unmarshall error", 122 | "schema": { 123 | "$ref": "#/definitions/models.Error" 124 | } 125 | } 126 | } 127 | } 128 | }, 129 | "/bootentries/{id}": { 130 | "get": { 131 | "description": "Get a Bootentry by Id", 132 | "consumes": [ 133 | "application/json" 134 | ], 135 | "produces": [ 136 | "application/json" 137 | ], 138 | "summary": "Get Bootentry", 139 | "parameters": [ 140 | { 141 | "maxLength": 36, 142 | "minLength": 36, 143 | "type": "string", 144 | "description": "Bootentry UUID", 145 | "name": "id", 146 | "in": "path", 147 | "required": true 148 | } 149 | ], 150 | "responses": { 151 | "200": { 152 | "description": "OK", 153 | "schema": { 154 | "$ref": "#/definitions/models.Bootentry" 155 | } 156 | }, 157 | "404": { 158 | "description": "Computer with uuid %s not found", 159 | "schema": { 160 | "$ref": "#/definitions/models.Error" 161 | } 162 | } 163 | } 164 | } 165 | }, 166 | "/bootentries/{username}": { 167 | "put": { 168 | "description": "Update a Bootentry", 169 | "consumes": [ 170 | "application/json" 171 | ], 172 | "produces": [ 173 | "application/json" 174 | ], 175 | "summary": "Update Bootentry", 176 | "parameters": [ 177 | { 178 | "maxLength": 36, 179 | "minLength": 36, 180 | "type": "string", 181 | "description": "Bootentry UUID", 182 | "name": "uuid", 183 | "in": "path", 184 | "required": true 185 | }, 186 | { 187 | "description": "json format of Bootentry", 188 | "name": "bootentry", 189 | "in": "body", 190 | "required": true, 191 | "schema": { 192 | "$ref": "#/definitions/models.Bootentry" 193 | } 194 | } 195 | ], 196 | "responses": { 197 | "200": { 198 | "description": "OK", 199 | "schema": { 200 | "$ref": "#/definitions/models.Bootentry" 201 | } 202 | }, 203 | "400": { 204 | "description": "Query uuid and uuid miss match", 205 | "schema": { 206 | "$ref": "#/definitions/models.Error" 207 | } 208 | }, 209 | "404": { 210 | "description": "Bootentry UUID not found", 211 | "schema": { 212 | "$ref": "#/definitions/models.Error" 213 | } 214 | }, 215 | "500": { 216 | "description": "Unmarshall error", 217 | "schema": { 218 | "$ref": "#/definitions/models.Error" 219 | } 220 | } 221 | } 222 | } 223 | }, 224 | "/computers": { 225 | "get": { 226 | "description": "List of computers filtered or not", 227 | "consumes": [ 228 | "application/json" 229 | ], 230 | "produces": [ 231 | "application/json" 232 | ], 233 | "summary": "List computers", 234 | "parameters": [ 235 | { 236 | "type": "integer", 237 | "description": "Offset", 238 | "name": "_start", 239 | "in": "query" 240 | } 241 | ], 242 | "responses": { 243 | "200": { 244 | "description": "OK", 245 | "schema": { 246 | "type": "array", 247 | "items": { 248 | "$ref": "#/definitions/models.Computer" 249 | } 250 | } 251 | } 252 | } 253 | } 254 | }, 255 | "/computers/{id}": { 256 | "get": { 257 | "description": "Get a computer by Id", 258 | "consumes": [ 259 | "application/json" 260 | ], 261 | "produces": [ 262 | "application/json" 263 | ], 264 | "summary": "Get computer", 265 | "parameters": [ 266 | { 267 | "maxLength": 36, 268 | "minLength": 36, 269 | "type": "string", 270 | "description": "Computer UUID", 271 | "name": "id", 272 | "in": "path", 273 | "required": true 274 | } 275 | ], 276 | "responses": { 277 | "200": { 278 | "description": "OK", 279 | "schema": { 280 | "$ref": "#/definitions/models.Computer" 281 | } 282 | }, 283 | "404": { 284 | "description": "Computer with uuid %s not found", 285 | "schema": { 286 | "$ref": "#/definitions/models.Error" 287 | } 288 | } 289 | } 290 | }, 291 | "put": { 292 | "description": "Update a computer", 293 | "consumes": [ 294 | "application/json" 295 | ], 296 | "produces": [ 297 | "application/json" 298 | ], 299 | "summary": "Update computer", 300 | "parameters": [ 301 | { 302 | "maxLength": 36, 303 | "minLength": 36, 304 | "type": "string", 305 | "description": "Computer UUID", 306 | "name": "id", 307 | "in": "path", 308 | "required": true 309 | }, 310 | { 311 | "description": "json format computer", 312 | "name": "computer", 313 | "in": "body", 314 | "required": true, 315 | "schema": { 316 | "$ref": "#/definitions/models.Computer" 317 | } 318 | } 319 | ], 320 | "responses": { 321 | "200": { 322 | "description": "OK", 323 | "schema": { 324 | "$ref": "#/definitions/models.Computer" 325 | } 326 | }, 327 | "400": { 328 | "description": "Query ID and UUID miss match", 329 | "schema": { 330 | "$ref": "#/definitions/models.Error" 331 | } 332 | }, 333 | "404": { 334 | "description": "Can not find ID", 335 | "schema": { 336 | "$ref": "#/definitions/models.Error" 337 | } 338 | }, 339 | "500": { 340 | "description": "Unmarshall error", 341 | "schema": { 342 | "$ref": "#/definitions/models.Error" 343 | } 344 | } 345 | } 346 | }, 347 | "delete": { 348 | "description": "Delete a computer", 349 | "consumes": [ 350 | "application/json" 351 | ], 352 | "produces": [ 353 | "application/json" 354 | ], 355 | "summary": "Delete computer", 356 | "parameters": [ 357 | { 358 | "maxLength": 36, 359 | "minLength": 36, 360 | "type": "string", 361 | "description": "Computer UUID", 362 | "name": "id", 363 | "in": "path", 364 | "required": true 365 | } 366 | ], 367 | "responses": { 368 | "200": { 369 | "description": "" 370 | }, 371 | "400": { 372 | "description": "Failed to parse UUID", 373 | "schema": { 374 | "$ref": "#/definitions/models.Error" 375 | } 376 | }, 377 | "404": { 378 | "description": "Can not find ID", 379 | "schema": { 380 | "$ref": "#/definitions/models.Error" 381 | } 382 | } 383 | } 384 | } 385 | }, 386 | "/ipxeaccount": { 387 | "get": { 388 | "description": "List of accounts for ipxe", 389 | "consumes": [ 390 | "application/json" 391 | ], 392 | "produces": [ 393 | "application/json" 394 | ], 395 | "summary": "List iPXE account", 396 | "parameters": [ 397 | { 398 | "type": "integer", 399 | "description": "Offset", 400 | "name": "_start", 401 | "in": "query" 402 | } 403 | ], 404 | "responses": { 405 | "200": { 406 | "description": "OK", 407 | "schema": { 408 | "type": "array", 409 | "items": { 410 | "$ref": "#/definitions/models.Ipxeaccount" 411 | } 412 | } 413 | } 414 | } 415 | }, 416 | "post": { 417 | "description": "Create a iPXE account", 418 | "consumes": [ 419 | "application/json" 420 | ], 421 | "produces": [ 422 | "application/json" 423 | ], 424 | "summary": "Create iPXE account", 425 | "parameters": [ 426 | { 427 | "description": "json format iPXE account", 428 | "name": "ipxeaccount", 429 | "in": "body", 430 | "required": true, 431 | "schema": { 432 | "$ref": "#/definitions/models.Ipxeaccount" 433 | } 434 | } 435 | ], 436 | "responses": { 437 | "200": { 438 | "description": "OK", 439 | "schema": { 440 | "$ref": "#/definitions/models.Ipxeaccount" 441 | } 442 | }, 443 | "400": { 444 | "description": "Failed to create account in DB", 445 | "schema": { 446 | "$ref": "#/definitions/models.Error" 447 | } 448 | }, 449 | "500": { 450 | "description": "Unmarshall error", 451 | "schema": { 452 | "$ref": "#/definitions/models.Error" 453 | } 454 | } 455 | } 456 | } 457 | }, 458 | "/ipxeaccount/{username}": { 459 | "get": { 460 | "description": "Get iPXE account by username", 461 | "consumes": [ 462 | "application/json" 463 | ], 464 | "produces": [ 465 | "application/json" 466 | ], 467 | "summary": "Get iPXE account", 468 | "parameters": [ 469 | { 470 | "type": "string", 471 | "description": "Username", 472 | "name": "username", 473 | "in": "path", 474 | "required": true 475 | } 476 | ], 477 | "responses": { 478 | "200": { 479 | "description": "OK", 480 | "schema": { 481 | "$ref": "#/definitions/models.Ipxeaccount" 482 | } 483 | }, 484 | "404": { 485 | "description": "iPXE account not found", 486 | "schema": { 487 | "$ref": "#/definitions/models.Error" 488 | } 489 | } 490 | } 491 | }, 492 | "put": { 493 | "description": "Update a iPXE account", 494 | "consumes": [ 495 | "application/json" 496 | ], 497 | "produces": [ 498 | "application/json" 499 | ], 500 | "summary": "Update iPXE account", 501 | "parameters": [ 502 | { 503 | "type": "string", 504 | "description": "Username", 505 | "name": "username", 506 | "in": "path", 507 | "required": true 508 | }, 509 | { 510 | "description": "json format iPXE account", 511 | "name": "ipxeaccount", 512 | "in": "body", 513 | "required": true, 514 | "schema": { 515 | "$ref": "#/definitions/models.Ipxeaccount" 516 | } 517 | } 518 | ], 519 | "responses": { 520 | "200": { 521 | "description": "OK", 522 | "schema": { 523 | "$ref": "#/definitions/models.Ipxeaccount" 524 | } 525 | }, 526 | "400": { 527 | "description": "Query username and username miss match", 528 | "schema": { 529 | "$ref": "#/definitions/models.Error" 530 | } 531 | }, 532 | "404": { 533 | "description": "iPXE account not found", 534 | "schema": { 535 | "$ref": "#/definitions/models.Error" 536 | } 537 | }, 538 | "500": { 539 | "description": "Unmarshall error", 540 | "schema": { 541 | "$ref": "#/definitions/models.Error" 542 | } 543 | } 544 | } 545 | }, 546 | "delete": { 547 | "description": "Delete a iPXE account", 548 | "consumes": [ 549 | "application/json" 550 | ], 551 | "produces": [ 552 | "application/json" 553 | ], 554 | "summary": "Delete iPXE account", 555 | "parameters": [ 556 | { 557 | "type": "string", 558 | "description": "Username", 559 | "name": "username", 560 | "in": "path", 561 | "required": true 562 | } 563 | ], 564 | "responses": { 565 | "200": { 566 | "description": "OK", 567 | "schema": { 568 | "$ref": "#/definitions/models.Ipxeaccount" 569 | } 570 | }, 571 | "404": { 572 | "description": "iPXE account not found", 573 | "schema": { 574 | "$ref": "#/definitions/models.Error" 575 | } 576 | } 577 | } 578 | } 579 | } 580 | }, 581 | "definitions": { 582 | "models.Bootentry": { 583 | "type": "object", 584 | "properties": { 585 | "created_at": { 586 | "type": "string" 587 | }, 588 | "description": { 589 | "type": "string" 590 | }, 591 | "files": { 592 | "type": "array", 593 | "items": { 594 | "$ref": "#/definitions/models.BootentryFile" 595 | } 596 | }, 597 | "id": { 598 | "type": "string" 599 | }, 600 | "ipxe_script": { 601 | "type": "string" 602 | }, 603 | "name": { 604 | "type": "string" 605 | }, 606 | "persistent": { 607 | "type": "boolean" 608 | }, 609 | "updated_at": { 610 | "type": "string" 611 | } 612 | } 613 | }, 614 | "models.BootentryFile": { 615 | "type": "object", 616 | "properties": { 617 | "bootentry": { 618 | "$ref": "#/definitions/models.Bootentry" 619 | }, 620 | "name": { 621 | "type": "string" 622 | }, 623 | "protected": { 624 | "type": "boolean" 625 | }, 626 | "subpath": { 627 | "type": "string" 628 | }, 629 | "templatized": { 630 | "type": "boolean" 631 | } 632 | } 633 | }, 634 | "models.Computer": { 635 | "type": "object", 636 | "properties": { 637 | "asset": { 638 | "type": "string" 639 | }, 640 | "bootentry": { 641 | "$ref": "#/definitions/models.Bootentry" 642 | }, 643 | "bootentry_uuid": { 644 | "type": "string" 645 | }, 646 | "build_arch": { 647 | "type": "string" 648 | }, 649 | "created_at": { 650 | "type": "string" 651 | }, 652 | "hostname": { 653 | "type": "string" 654 | }, 655 | "id": { 656 | "type": "string" 657 | }, 658 | "last_ipxeaccount": { 659 | "type": "string" 660 | }, 661 | "last_seen": { 662 | "type": "string" 663 | }, 664 | "manufacturer": { 665 | "type": "string" 666 | }, 667 | "name": { 668 | "type": "string" 669 | }, 670 | "platform": { 671 | "type": "string" 672 | }, 673 | "product": { 674 | "type": "string" 675 | }, 676 | "serial": { 677 | "type": "string" 678 | }, 679 | "tags": { 680 | "type": "array", 681 | "items": { 682 | "$ref": "#/definitions/models.Tag" 683 | } 684 | }, 685 | "updated_at": { 686 | "type": "string" 687 | }, 688 | "version": { 689 | "type": "string" 690 | } 691 | } 692 | }, 693 | "models.Error": { 694 | "type": "object", 695 | "properties": { 696 | "error": { 697 | "type": "string" 698 | } 699 | } 700 | }, 701 | "models.Ipxeaccount": { 702 | "type": "object", 703 | "properties": { 704 | "created_at": { 705 | "type": "string" 706 | }, 707 | "is_admin": { 708 | "type": "boolean" 709 | }, 710 | "last_login": { 711 | "type": "string" 712 | }, 713 | "password": { 714 | "type": "string" 715 | }, 716 | "updated_at": { 717 | "type": "string" 718 | }, 719 | "username": { 720 | "type": "string" 721 | } 722 | } 723 | }, 724 | "models.Tag": { 725 | "type": "object", 726 | "properties": { 727 | "key": { 728 | "type": "string" 729 | }, 730 | "value": { 731 | "type": "string" 732 | } 733 | } 734 | } 735 | } 736 | } -------------------------------------------------------------------------------- /docs/docs.go: -------------------------------------------------------------------------------- 1 | // GENERATED BY THE COMMAND ABOVE; DO NOT EDIT 2 | // This file was generated by swaggo/swag 3 | 4 | package docs 5 | 6 | import ( 7 | "bytes" 8 | "encoding/json" 9 | "strings" 10 | 11 | "github.com/alecthomas/template" 12 | "github.com/swaggo/swag" 13 | ) 14 | 15 | var doc = `{ 16 | "schemes": {{ marshal .Schemes }}, 17 | "swagger": "2.0", 18 | "info": { 19 | "description": "{{.Description}}", 20 | "title": "{{.Title}}", 21 | "termsOfService": "http://swagger.io/terms/", 22 | "contact": {}, 23 | "license": { 24 | "name": "Apache 2.0", 25 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 26 | }, 27 | "version": "{{.Version}}" 28 | }, 29 | "host": "{{.Host}}", 30 | "basePath": "{{.BasePath}}", 31 | "paths": { 32 | "/Bootentries/{username}": { 33 | "delete": { 34 | "description": "Delete Bootentry", 35 | "consumes": [ 36 | "application/json" 37 | ], 38 | "produces": [ 39 | "application/json" 40 | ], 41 | "summary": "Delete Bootentry", 42 | "parameters": [ 43 | { 44 | "maxLength": 36, 45 | "minLength": 36, 46 | "type": "string", 47 | "description": "Bootentry UUID", 48 | "name": "uuid", 49 | "in": "path", 50 | "required": true 51 | } 52 | ], 53 | "responses": { 54 | "200": { 55 | "description": "" 56 | }, 57 | "400": { 58 | "description": "Failed to parse UUID", 59 | "schema": { 60 | "$ref": "#/definitions/models.Error" 61 | } 62 | }, 63 | "404": { 64 | "description": "Bootentry UUID not found", 65 | "schema": { 66 | "$ref": "#/definitions/models.Error" 67 | } 68 | } 69 | } 70 | } 71 | }, 72 | "/bootentries": { 73 | "get": { 74 | "description": "List of Bootentry filtered or not", 75 | "consumes": [ 76 | "application/json" 77 | ], 78 | "produces": [ 79 | "application/json" 80 | ], 81 | "summary": "List Bootentries", 82 | "parameters": [ 83 | { 84 | "type": "integer", 85 | "description": "Offset", 86 | "name": "_start", 87 | "in": "query" 88 | } 89 | ], 90 | "responses": { 91 | "200": { 92 | "description": "OK", 93 | "schema": { 94 | "type": "array", 95 | "items": { 96 | "$ref": "#/definitions/models.Bootentry" 97 | } 98 | } 99 | } 100 | } 101 | }, 102 | "post": { 103 | "description": "Create a Bootentry", 104 | "consumes": [ 105 | "application/json" 106 | ], 107 | "produces": [ 108 | "application/json" 109 | ], 110 | "summary": "Create Bootentry", 111 | "parameters": [ 112 | { 113 | "description": "json format Bootentry", 114 | "name": "bootentry", 115 | "in": "body", 116 | "required": true, 117 | "schema": { 118 | "$ref": "#/definitions/models.Bootentry" 119 | } 120 | } 121 | ], 122 | "responses": { 123 | "200": { 124 | "description": "OK", 125 | "schema": { 126 | "$ref": "#/definitions/models.Bootentry" 127 | } 128 | }, 129 | "400": { 130 | "description": "Failed to create account in DB", 131 | "schema": { 132 | "$ref": "#/definitions/models.Error" 133 | } 134 | }, 135 | "500": { 136 | "description": "Unmarshall error", 137 | "schema": { 138 | "$ref": "#/definitions/models.Error" 139 | } 140 | } 141 | } 142 | } 143 | }, 144 | "/bootentries/{id}": { 145 | "get": { 146 | "description": "Get a Bootentry by Id", 147 | "consumes": [ 148 | "application/json" 149 | ], 150 | "produces": [ 151 | "application/json" 152 | ], 153 | "summary": "Get Bootentry", 154 | "parameters": [ 155 | { 156 | "maxLength": 36, 157 | "minLength": 36, 158 | "type": "string", 159 | "description": "Bootentry UUID", 160 | "name": "id", 161 | "in": "path", 162 | "required": true 163 | } 164 | ], 165 | "responses": { 166 | "200": { 167 | "description": "OK", 168 | "schema": { 169 | "$ref": "#/definitions/models.Bootentry" 170 | } 171 | }, 172 | "404": { 173 | "description": "Computer with uuid %s not found", 174 | "schema": { 175 | "$ref": "#/definitions/models.Error" 176 | } 177 | } 178 | } 179 | } 180 | }, 181 | "/bootentries/{username}": { 182 | "put": { 183 | "description": "Update a Bootentry", 184 | "consumes": [ 185 | "application/json" 186 | ], 187 | "produces": [ 188 | "application/json" 189 | ], 190 | "summary": "Update Bootentry", 191 | "parameters": [ 192 | { 193 | "maxLength": 36, 194 | "minLength": 36, 195 | "type": "string", 196 | "description": "Bootentry UUID", 197 | "name": "uuid", 198 | "in": "path", 199 | "required": true 200 | }, 201 | { 202 | "description": "json format of Bootentry", 203 | "name": "bootentry", 204 | "in": "body", 205 | "required": true, 206 | "schema": { 207 | "$ref": "#/definitions/models.Bootentry" 208 | } 209 | } 210 | ], 211 | "responses": { 212 | "200": { 213 | "description": "OK", 214 | "schema": { 215 | "$ref": "#/definitions/models.Bootentry" 216 | } 217 | }, 218 | "400": { 219 | "description": "Query uuid and uuid miss match", 220 | "schema": { 221 | "$ref": "#/definitions/models.Error" 222 | } 223 | }, 224 | "404": { 225 | "description": "Bootentry UUID not found", 226 | "schema": { 227 | "$ref": "#/definitions/models.Error" 228 | } 229 | }, 230 | "500": { 231 | "description": "Unmarshall error", 232 | "schema": { 233 | "$ref": "#/definitions/models.Error" 234 | } 235 | } 236 | } 237 | } 238 | }, 239 | "/computers": { 240 | "get": { 241 | "description": "List of computers filtered or not", 242 | "consumes": [ 243 | "application/json" 244 | ], 245 | "produces": [ 246 | "application/json" 247 | ], 248 | "summary": "List computers", 249 | "parameters": [ 250 | { 251 | "type": "integer", 252 | "description": "Offset", 253 | "name": "_start", 254 | "in": "query" 255 | } 256 | ], 257 | "responses": { 258 | "200": { 259 | "description": "OK", 260 | "schema": { 261 | "type": "array", 262 | "items": { 263 | "$ref": "#/definitions/models.Computer" 264 | } 265 | } 266 | } 267 | } 268 | } 269 | }, 270 | "/computers/{id}": { 271 | "get": { 272 | "description": "Get a computer by Id", 273 | "consumes": [ 274 | "application/json" 275 | ], 276 | "produces": [ 277 | "application/json" 278 | ], 279 | "summary": "Get computer", 280 | "parameters": [ 281 | { 282 | "maxLength": 36, 283 | "minLength": 36, 284 | "type": "string", 285 | "description": "Computer UUID", 286 | "name": "id", 287 | "in": "path", 288 | "required": true 289 | } 290 | ], 291 | "responses": { 292 | "200": { 293 | "description": "OK", 294 | "schema": { 295 | "$ref": "#/definitions/models.Computer" 296 | } 297 | }, 298 | "404": { 299 | "description": "Computer with uuid %s not found", 300 | "schema": { 301 | "$ref": "#/definitions/models.Error" 302 | } 303 | } 304 | } 305 | }, 306 | "put": { 307 | "description": "Update a computer", 308 | "consumes": [ 309 | "application/json" 310 | ], 311 | "produces": [ 312 | "application/json" 313 | ], 314 | "summary": "Update computer", 315 | "parameters": [ 316 | { 317 | "maxLength": 36, 318 | "minLength": 36, 319 | "type": "string", 320 | "description": "Computer UUID", 321 | "name": "id", 322 | "in": "path", 323 | "required": true 324 | }, 325 | { 326 | "description": "json format computer", 327 | "name": "computer", 328 | "in": "body", 329 | "required": true, 330 | "schema": { 331 | "$ref": "#/definitions/models.Computer" 332 | } 333 | } 334 | ], 335 | "responses": { 336 | "200": { 337 | "description": "OK", 338 | "schema": { 339 | "$ref": "#/definitions/models.Computer" 340 | } 341 | }, 342 | "400": { 343 | "description": "Query ID and UUID miss match", 344 | "schema": { 345 | "$ref": "#/definitions/models.Error" 346 | } 347 | }, 348 | "404": { 349 | "description": "Can not find ID", 350 | "schema": { 351 | "$ref": "#/definitions/models.Error" 352 | } 353 | }, 354 | "500": { 355 | "description": "Unmarshall error", 356 | "schema": { 357 | "$ref": "#/definitions/models.Error" 358 | } 359 | } 360 | } 361 | }, 362 | "delete": { 363 | "description": "Delete a computer", 364 | "consumes": [ 365 | "application/json" 366 | ], 367 | "produces": [ 368 | "application/json" 369 | ], 370 | "summary": "Delete computer", 371 | "parameters": [ 372 | { 373 | "maxLength": 36, 374 | "minLength": 36, 375 | "type": "string", 376 | "description": "Computer UUID", 377 | "name": "id", 378 | "in": "path", 379 | "required": true 380 | } 381 | ], 382 | "responses": { 383 | "200": { 384 | "description": "" 385 | }, 386 | "400": { 387 | "description": "Failed to parse UUID", 388 | "schema": { 389 | "$ref": "#/definitions/models.Error" 390 | } 391 | }, 392 | "404": { 393 | "description": "Can not find ID", 394 | "schema": { 395 | "$ref": "#/definitions/models.Error" 396 | } 397 | } 398 | } 399 | } 400 | }, 401 | "/ipxeaccount": { 402 | "get": { 403 | "description": "List of accounts for ipxe", 404 | "consumes": [ 405 | "application/json" 406 | ], 407 | "produces": [ 408 | "application/json" 409 | ], 410 | "summary": "List iPXE account", 411 | "parameters": [ 412 | { 413 | "type": "integer", 414 | "description": "Offset", 415 | "name": "_start", 416 | "in": "query" 417 | } 418 | ], 419 | "responses": { 420 | "200": { 421 | "description": "OK", 422 | "schema": { 423 | "type": "array", 424 | "items": { 425 | "$ref": "#/definitions/models.Ipxeaccount" 426 | } 427 | } 428 | } 429 | } 430 | }, 431 | "post": { 432 | "description": "Create a iPXE account", 433 | "consumes": [ 434 | "application/json" 435 | ], 436 | "produces": [ 437 | "application/json" 438 | ], 439 | "summary": "Create iPXE account", 440 | "parameters": [ 441 | { 442 | "description": "json format iPXE account", 443 | "name": "ipxeaccount", 444 | "in": "body", 445 | "required": true, 446 | "schema": { 447 | "$ref": "#/definitions/models.Ipxeaccount" 448 | } 449 | } 450 | ], 451 | "responses": { 452 | "200": { 453 | "description": "OK", 454 | "schema": { 455 | "$ref": "#/definitions/models.Ipxeaccount" 456 | } 457 | }, 458 | "400": { 459 | "description": "Failed to create account in DB", 460 | "schema": { 461 | "$ref": "#/definitions/models.Error" 462 | } 463 | }, 464 | "500": { 465 | "description": "Unmarshall error", 466 | "schema": { 467 | "$ref": "#/definitions/models.Error" 468 | } 469 | } 470 | } 471 | } 472 | }, 473 | "/ipxeaccount/{username}": { 474 | "get": { 475 | "description": "Get iPXE account by username", 476 | "consumes": [ 477 | "application/json" 478 | ], 479 | "produces": [ 480 | "application/json" 481 | ], 482 | "summary": "Get iPXE account", 483 | "parameters": [ 484 | { 485 | "type": "string", 486 | "description": "Username", 487 | "name": "username", 488 | "in": "path", 489 | "required": true 490 | } 491 | ], 492 | "responses": { 493 | "200": { 494 | "description": "OK", 495 | "schema": { 496 | "$ref": "#/definitions/models.Ipxeaccount" 497 | } 498 | }, 499 | "404": { 500 | "description": "iPXE account not found", 501 | "schema": { 502 | "$ref": "#/definitions/models.Error" 503 | } 504 | } 505 | } 506 | }, 507 | "put": { 508 | "description": "Update a iPXE account", 509 | "consumes": [ 510 | "application/json" 511 | ], 512 | "produces": [ 513 | "application/json" 514 | ], 515 | "summary": "Update iPXE account", 516 | "parameters": [ 517 | { 518 | "type": "string", 519 | "description": "Username", 520 | "name": "username", 521 | "in": "path", 522 | "required": true 523 | }, 524 | { 525 | "description": "json format iPXE account", 526 | "name": "ipxeaccount", 527 | "in": "body", 528 | "required": true, 529 | "schema": { 530 | "$ref": "#/definitions/models.Ipxeaccount" 531 | } 532 | } 533 | ], 534 | "responses": { 535 | "200": { 536 | "description": "OK", 537 | "schema": { 538 | "$ref": "#/definitions/models.Ipxeaccount" 539 | } 540 | }, 541 | "400": { 542 | "description": "Query username and username miss match", 543 | "schema": { 544 | "$ref": "#/definitions/models.Error" 545 | } 546 | }, 547 | "404": { 548 | "description": "iPXE account not found", 549 | "schema": { 550 | "$ref": "#/definitions/models.Error" 551 | } 552 | }, 553 | "500": { 554 | "description": "Unmarshall error", 555 | "schema": { 556 | "$ref": "#/definitions/models.Error" 557 | } 558 | } 559 | } 560 | }, 561 | "delete": { 562 | "description": "Delete a iPXE account", 563 | "consumes": [ 564 | "application/json" 565 | ], 566 | "produces": [ 567 | "application/json" 568 | ], 569 | "summary": "Delete iPXE account", 570 | "parameters": [ 571 | { 572 | "type": "string", 573 | "description": "Username", 574 | "name": "username", 575 | "in": "path", 576 | "required": true 577 | } 578 | ], 579 | "responses": { 580 | "200": { 581 | "description": "OK", 582 | "schema": { 583 | "$ref": "#/definitions/models.Ipxeaccount" 584 | } 585 | }, 586 | "404": { 587 | "description": "iPXE account not found", 588 | "schema": { 589 | "$ref": "#/definitions/models.Error" 590 | } 591 | } 592 | } 593 | } 594 | } 595 | }, 596 | "definitions": { 597 | "models.Bootentry": { 598 | "type": "object", 599 | "properties": { 600 | "created_at": { 601 | "type": "string" 602 | }, 603 | "description": { 604 | "type": "string" 605 | }, 606 | "files": { 607 | "type": "array", 608 | "items": { 609 | "$ref": "#/definitions/models.BootentryFile" 610 | } 611 | }, 612 | "id": { 613 | "type": "string" 614 | }, 615 | "ipxe_script": { 616 | "type": "string" 617 | }, 618 | "name": { 619 | "type": "string" 620 | }, 621 | "persistent": { 622 | "type": "boolean" 623 | }, 624 | "updated_at": { 625 | "type": "string" 626 | } 627 | } 628 | }, 629 | "models.BootentryFile": { 630 | "type": "object", 631 | "properties": { 632 | "bootentry": { 633 | "$ref": "#/definitions/models.Bootentry" 634 | }, 635 | "name": { 636 | "type": "string" 637 | }, 638 | "protected": { 639 | "type": "boolean" 640 | }, 641 | "subpath": { 642 | "type": "string" 643 | }, 644 | "templatized": { 645 | "type": "boolean" 646 | } 647 | } 648 | }, 649 | "models.Computer": { 650 | "type": "object", 651 | "properties": { 652 | "asset": { 653 | "type": "string" 654 | }, 655 | "bootentry": { 656 | "$ref": "#/definitions/models.Bootentry" 657 | }, 658 | "bootentry_uuid": { 659 | "type": "string" 660 | }, 661 | "build_arch": { 662 | "type": "string" 663 | }, 664 | "created_at": { 665 | "type": "string" 666 | }, 667 | "hostname": { 668 | "type": "string" 669 | }, 670 | "id": { 671 | "type": "string" 672 | }, 673 | "last_ipxeaccount": { 674 | "type": "string" 675 | }, 676 | "last_seen": { 677 | "type": "string" 678 | }, 679 | "manufacturer": { 680 | "type": "string" 681 | }, 682 | "name": { 683 | "type": "string" 684 | }, 685 | "platform": { 686 | "type": "string" 687 | }, 688 | "product": { 689 | "type": "string" 690 | }, 691 | "serial": { 692 | "type": "string" 693 | }, 694 | "tags": { 695 | "type": "array", 696 | "items": { 697 | "$ref": "#/definitions/models.Tag" 698 | } 699 | }, 700 | "updated_at": { 701 | "type": "string" 702 | }, 703 | "version": { 704 | "type": "string" 705 | } 706 | } 707 | }, 708 | "models.Error": { 709 | "type": "object", 710 | "properties": { 711 | "error": { 712 | "type": "string" 713 | } 714 | } 715 | }, 716 | "models.Ipxeaccount": { 717 | "type": "object", 718 | "properties": { 719 | "created_at": { 720 | "type": "string" 721 | }, 722 | "is_admin": { 723 | "type": "boolean" 724 | }, 725 | "last_login": { 726 | "type": "string" 727 | }, 728 | "password": { 729 | "type": "string" 730 | }, 731 | "updated_at": { 732 | "type": "string" 733 | }, 734 | "username": { 735 | "type": "string" 736 | } 737 | } 738 | }, 739 | "models.Tag": { 740 | "type": "object", 741 | "properties": { 742 | "key": { 743 | "type": "string" 744 | }, 745 | "value": { 746 | "type": "string" 747 | } 748 | } 749 | } 750 | } 751 | }` 752 | 753 | type swaggerInfo struct { 754 | Version string 755 | Host string 756 | BasePath string 757 | Schemes []string 758 | Title string 759 | Description string 760 | } 761 | 762 | // SwaggerInfo holds exported Swagger Info so clients can modify it 763 | var SwaggerInfo = swaggerInfo{ 764 | Version: "0.1", 765 | Host: "localhost:8080", 766 | BasePath: "/api/v1", 767 | Schemes: []string{}, 768 | Title: "ipxeblue API", 769 | Description: "Manage PXE boot", 770 | } 771 | 772 | type s struct{} 773 | 774 | func (s *s) ReadDoc() string { 775 | sInfo := SwaggerInfo 776 | sInfo.Description = strings.Replace(sInfo.Description, "\n", "\\n", -1) 777 | 778 | t, err := template.New("swagger_info").Funcs(template.FuncMap{ 779 | "marshal": func(v interface{}) string { 780 | a, _ := json.Marshal(v) 781 | return string(a) 782 | }, 783 | }).Parse(doc) 784 | if err != nil { 785 | return doc 786 | } 787 | 788 | var tpl bytes.Buffer 789 | if err := t.Execute(&tpl, sInfo); err != nil { 790 | return doc 791 | } 792 | 793 | return tpl.String() 794 | } 795 | 796 | func init() { 797 | swag.Register(swag.Name, &s{}) 798 | } 799 | --------------------------------------------------------------------------------