├── 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 | 
131 | 
132 | 
133 | 
134 | 
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------