Compare commits

...

9 commits
main ... backup

Author SHA1 Message Date
081626b434 backup 2024-04-30 18:43:01 +02:00
b250a6b3c8 Allow sending correct public name in autodiscovery 2024-03-27 22:07:09 +01:00
b1e9a2099e allow setting path to static files in config 2024-03-24 13:37:40 +01:00
e730e40a4a Add discovery to protocol 2024-03-24 13:21:56 +01:00
19b863e5ca use catppuccin-mocha
Some checks failed
continuous-integration/drone/push Build is failing
2023-09-16 15:39:08 +02:00
0318d5caf4 Allow deleting clients
Some checks failed
continuous-integration/drone/push Build is failing
2023-09-16 13:52:12 +02:00
0a58ceefbc fix multicast config for server 2023-09-16 13:52:05 +02:00
ebfd2ea96c Use device path to describe boot apps 2023-09-16 13:51:01 +02:00
2ae7327f6b Improve web interface and allow setting boot option 2023-08-19 13:49:13 +02:00
15 changed files with 547 additions and 142 deletions

View file

@ -3,9 +3,8 @@ package bootoption
import "github.com/google/uuid"
type EFIApp struct {
Name string `json:"name"`
Path string `json:"path"`
DiskID string `json:"disk_id"`
Name string `json:"name"`
DevicePath string `json:"device_path"`
}
type Client struct {

View file

@ -6,6 +6,7 @@ import (
"encoding"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
@ -16,11 +17,12 @@ const (
ActionRequest Action = iota
ActionAccept
ActionDeny
ActionDiscover
ActionUnknown
)
var spaceByte = []byte(" ")
var commaByte = []byte(",")
var commaByte = []byte(";")
const (
keyID = "id"
@ -41,6 +43,8 @@ func (a Action) String() string {
return "BOOT_DENY"
case ActionRequest:
return "BOOT_REQUEST"
case ActionDiscover:
return "BOOT_DISCOVER"
default:
return "unknown"
}
@ -54,6 +58,8 @@ func newActionFromBytes(raw []byte) Action {
return ActionDeny
case "BOOT_REQUEST":
return ActionRequest
case "BOOT_DISCOVER":
return ActionDiscover
default:
return ActionUnknown
}
@ -140,9 +146,13 @@ func (am *acceptMessage) UnmarshalBinary(data []byte) error {
func (am *acceptMessage) MarshalBinary() (data []byte, err error) {
action := []byte(am.Action().String())
// efiApp := strings.ReplaceAll(am.efiApp, `\`, `\\`)
// efiApp = strings.ReplaceAll(efiApp, "File(", "")
efiApp := strings.ReplaceAll(am.efiApp, "File(", "")
efiApp, _ = strings.CutSuffix(efiApp, ")")
params := [][]byte{
[]byte(fmt.Sprintf("%s=%s", keyID, am.id.String())),
[]byte(fmt.Sprintf("%s=%s", keyEfiApp, am.efiApp)),
[]byte(fmt.Sprintf("%s=%s", keyEfiApp, efiApp)),
}
param_bytes := bytes.Join(params, commaByte)
return bytes.Join([][]byte{action, param_bytes}, spaceByte), nil
@ -212,11 +222,30 @@ func (dm *denyMessage) String() string {
return fmt.Sprintf("%s from %s, reason %q", ActionDeny.String(), dm.ID().String(), dm.reason)
}
type discoverMessage struct{}
func (dm *discoverMessage) UnmarshalBinary(data []byte) error {
return nil
}
func (dm *discoverMessage) MarshalBinary() (data []byte, err error) {
return []byte(dm.Action().String()), nil
}
func (dm *discoverMessage) Action() Action {
return ActionDiscover
}
func (dm *discoverMessage) ID() uuid.UUID {
return uuid.Nil
}
func (dm *discoverMessage) String() string {
return ActionDiscover.String()
}
func MessageFromBytes(dat []byte) (Message, error) {
rawAction, content, found := bytes.Cut(dat, spaceByte)
if !found {
return nil, ErrInvalidFormat
}
rawAction, content, _ := bytes.Cut(dat, spaceByte)
var message Message
action := newActionFromBytes(rawAction)
@ -227,12 +256,14 @@ func MessageFromBytes(dat []byte) (Message, error) {
message = &acceptMessage{}
case ActionDeny:
message = &denyMessage{}
case ActionDiscover:
message = &discoverMessage{}
default:
return nil, ErrUnknownAction
}
if err := message.UnmarshalBinary(content); err != nil {
return nil, fmt.Errorf("failed to parse message: %w", err)
return nil, fmt.Errorf("failed to parse %s message: %w", message.Action().String(), err)
}
return message, nil
}

View file

@ -43,31 +43,46 @@ type jsonConf struct {
Level string `json:"level"`
} `json:"log"`
Storage struct {
Path string `json:"path"`
Path string `json:"path"`
TemplateDir string `json:"templatePath"`
StaticDir string `json:"staticPath"`
} `json:"storage"`
Server struct {
Host string `json:"host"`
Port int `json:"port"`
Mode string `json:"mode"`
SockPath string `json:"sock"`
Host string `json:"host"`
Port int `json:"port"`
Mode string `json:"mode"`
SockPath string `json:"sock"`
PublicHost string `json:"public_host"`
} `json:"server"`
BootProvider struct {
Iface string `json:"interface"`
Port int `json:"port"`
McastGroup string `json:"multicast_group"`
SrcAddr string `json:"src_addr"`
} `json:"boot_provider"`
HomeAssistant struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
APIToken string `json:"token"`
} `json:"home_assistant"`
}
type AppConfig struct {
LogLevel logrus.Level
ServerMode ListeningMode
DataFilepath string
Host string
Port int
SockPath string
UPDMcastGroup string
UDPPort int
UDPIface string
LogLevel logrus.Level
ServerMode ListeningMode
DataFilepath string
StaticDir string
Host string
PublicHost string
Port int
SockPath string
UPDMcastGroup string
UDPPort int
UDPIface string
UDPSrcAddr string
HomeAssistantEnabled bool
HomeAssistantHost string
HomeAssistantToken string
}
func parseLevel(lvlStr string) logrus.Level {
@ -93,11 +108,17 @@ func (ac *AppConfig) UnmarshalJSON(data []byte) error {
ac.ServerMode = lm
ac.SockPath = jsonConf.Server.SockPath
ac.Host = jsonConf.Server.Host
ac.PublicHost = jsonConf.Server.PublicHost
ac.Port = jsonConf.Server.Port
ac.UPDMcastGroup = jsonConf.BootProvider.McastGroup
ac.UDPIface = jsonConf.BootProvider.Iface
ac.UDPPort = jsonConf.BootProvider.Port
ac.UDPSrcAddr = jsonConf.BootProvider.SrcAddr
ac.DataFilepath = jsonConf.Storage.Path
ac.StaticDir = jsonConf.Storage.StaticDir
ac.HomeAssistantEnabled = jsonConf.HomeAssistant.Enabled
ac.HomeAssistantHost = jsonConf.HomeAssistant.Host
ac.HomeAssistantToken = jsonConf.HomeAssistant.APIToken
return nil
}
@ -106,10 +127,12 @@ var defaultConfig AppConfig = AppConfig{
ServerMode: ModeNet,
DataFilepath: "boot_options.json",
Host: "0.0.0.0",
PublicHost: "http://127.0.0.1:5000",
Port: 5000,
UPDMcastGroup: "ff02::abcd:1234",
UDPPort: 42,
UDPIface: "eth0",
StaticDir: "./static",
}
func New(filepath string) (*AppConfig, error) {

View file

@ -1,85 +1,126 @@
package client
// import (
// "encoding/json"
// "fmt"
// "io"
// "net"
// "net/http"
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
// "git.faercol.me/faercol/http-boot-server/bootserver/helpers"
// "git.faercol.me/faercol/http-boot-server/bootserver/services"
// "github.com/sirupsen/logrus"
// )
"git.faercol.me/faercol/http-boot-server/bootserver/config"
"git.faercol.me/faercol/http-boot-server/bootserver/helpers"
"git.faercol.me/faercol/http-boot-server/bootserver/homeassistant"
"git.faercol.me/faercol/http-boot-server/bootserver/services"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
)
// const BootRoute = "/boot"
const SetBootRoute = "/config/boot"
// type BootController struct {
// clientService *services.ClientHandlerService
// l *logrus.Logger
// }
type setBootOptionPayload struct {
ClientID string `json:"client_id"`
OptionID string `json:"option_id"`
}
// func NewBootController(logger *logrus.Logger, service *services.ClientHandlerService) *BootController {
// return &BootController{
// clientService: service,
// l: logger,
// }
// }
type BootController struct {
clientService *services.ClientHandlerService
appConf *config.AppConfig
l *logrus.Logger
}
// func (bc *BootController) getBootOption(clientIP string, w http.ResponseWriter, r *http.Request) (int, []byte, error) {
// bootOption, err := bc.clientService.GetClientSelectedBootOption(clientIP)
// if err != nil {
// return http.StatusInternalServerError, nil, fmt.Errorf("failed to get boot option: %w", err)
// }
func NewBootController(logger *logrus.Logger, service *services.ClientHandlerService, conf *config.AppConfig) *BootController {
return &BootController{
clientService: service,
l: logger,
appConf: conf,
}
}
// dat, err := json.Marshal(bootOption)
// if err != nil {
// return http.StatusInternalServerError, nil, fmt.Errorf("failed to serialize body")
// }
func (bc *BootController) setBootOption(w http.ResponseWriter, r *http.Request) (int, []byte, error) {
dat, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusInternalServerError, nil, fmt.Errorf("failed to read body: %w", err)
}
var payload setBootOptionPayload
if err := json.Unmarshal(dat, &payload); err != nil {
return http.StatusBadRequest, nil, fmt.Errorf("failed to parse body: %w", err)
}
// w.Header().Add("Content-Type", "application/json")
// return http.StatusOK, dat, nil
// }
clientID, err := uuid.Parse(payload.ClientID)
if err != nil {
return http.StatusBadRequest, []byte("bad client ID"), fmt.Errorf("invalid format for client ID: %w", err)
}
optionID, err := uuid.Parse(payload.OptionID)
if err != nil {
return http.StatusBadRequest, []byte("bad option ID"), fmt.Errorf("invalid format for option ID: %w", err)
}
// func (bc *BootController) setBootOption(clientIP string, w http.ResponseWriter, r *http.Request) (int, error) {
// dat, err := io.ReadAll(r.Body)
// if err != nil {
// return http.StatusInternalServerError, fmt.Errorf("failed to read body: %w", err)
// }
// var option string
// if err := json.Unmarshal(dat, &option); err != nil {
// return http.StatusInternalServerError, fmt.Errorf("failed to parse body: %w", err)
// }
if err := bc.clientService.SetClientBootOption(clientID, optionID.String()); err != nil {
if errors.Is(err, services.ErrUnknownClient) {
return http.StatusNotFound, []byte("unknown client"), err
}
if errors.Is(err, services.ErrUnknownBootOption) {
return http.StatusNotFound, []byte("unknown boot option"), err
}
return http.StatusInternalServerError, nil, fmt.Errorf("failed to set boot option for client: %w", err)
}
// if err := bc.clientService.SetClientBootOption(clientIP, option); err != nil {
// return http.StatusInternalServerError, fmt.Errorf("failed to set boot option for client: %w", err)
// }
if bc.appConf.HomeAssistantEnabled {
bc.l.Debug("Notifying HomeAssistant of change")
newConf, err := bc.clientService.GetClientConfig(clientID)
if err != nil {
bc.l.Errorf("Failed to get new config to send to HA: %s", err.Error())
} else {
if err := homeassistant.New(bc.appConf).SendBootOption(r.Context(), newConf.Name, newConf.Options[newConf.SelectedOption].Name); err != nil {
bc.l.Errorf("Failed to notify HA: %s", err.Error())
}
}
}
// return http.StatusAccepted, nil
// }
return http.StatusAccepted, nil, nil
}
// func (bc *BootController) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
// if err != nil {
// bc.l.Errorf("Failed to read remote IP: %s", err.Error())
// helpers.HandleResponse(w, r, http.StatusInternalServerError, nil, bc.l)
// return
// }
func (bc *BootController) deleteClient(w http.ResponseWriter, r *http.Request) (int, []byte, error) {
dat, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusInternalServerError, nil, fmt.Errorf("failed to read body: %w", err)
}
var payload setBootOptionPayload
if err := json.Unmarshal(dat, &payload); err != nil {
return http.StatusBadRequest, nil, fmt.Errorf("failed to parse body: %w", err)
}
// var returncode int
// var content []byte
clientID, err := uuid.Parse(payload.ClientID)
if err != nil {
return http.StatusBadRequest, []byte("bad client ID"), fmt.Errorf("invalid format for client ID: %w", err)
}
if err := bc.clientService.DeleteClient(clientID); err != nil {
return http.StatusInternalServerError, []byte("failed to delete client"), fmt.Errorf("failed to delete client: %w", err)
}
// switch r.Method {
// case http.MethodGet:
// returncode, content, err = bc.getBootOption(clientIP, w, r)
// case http.MethodPut:
// returncode, err = bc.setBootOption(clientIP, w, r)
// default:
// returncode = http.StatusMethodNotAllowed
// }
return http.StatusOK, nil, nil
}
// if err != nil {
// bc.l.Errorf("An error occured while handling boot request: %q", err.Error())
// }
// helpers.HandleResponse(w, r, returncode, content, bc.l)
// }
func (bc *BootController) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var returncode int
var content []byte
var err error
switch r.Method {
case http.MethodPut:
returncode, content, err = bc.setBootOption(w, r)
if err != nil {
bc.l.Errorf("Error setting boot option for client: %s", err.Error())
}
case http.MethodDelete:
returncode, content, err = bc.deleteClient(w, r)
if err != nil {
bc.l.Errorf("Error setting boot option for client: %s", err.Error())
}
default:
helpers.HandleResponse(w, r, http.StatusMethodNotAllowed, nil, bc.l)
return
}
helpers.HandleResponse(w, r, returncode, content, bc.l)
}

View file

@ -15,18 +15,25 @@ import (
const EnrollRoute = "/enroll"
type newClientPayload struct {
ID string `json:"ID"`
ID string `json:"ID"`
MulticastGroup string `json:"multicast_group"`
MulticastPort int `json:"multicast_port"`
}
type EnrollController struct {
clientService *services.ClientHandlerService
l *logrus.Logger
clientService *services.ClientHandlerService
l *logrus.Logger
multicastPort int
multicastGroup string
}
func NewEnrollController(l *logrus.Logger, service *services.ClientHandlerService) *EnrollController {
func NewEnrollController(l *logrus.Logger, service *services.ClientHandlerService, mcastPort int, mcastGroup string) *EnrollController {
return &EnrollController{
clientService: service,
l: l,
clientService: service,
l: l,
multicastPort: mcastPort,
multicastGroup: mcastGroup,
}
}
@ -50,12 +57,13 @@ func (ec *EnrollController) enrollMachine(w http.ResponseWriter, r *http.Request
return http.StatusInternalServerError, nil, fmt.Errorf("failed to create client %w", err)
}
payload, err := json.Marshal(newClientPayload{ID: cltID.String()})
payload, err := json.Marshal(newClientPayload{ID: cltID.String(), MulticastGroup: ec.multicastGroup, MulticastPort: ec.multicastPort})
if err != nil {
return http.StatusInternalServerError, nil, fmt.Errorf("failed to serialize payload: %w", err)
}
ec.l.Infof("Added client")
w.Header().Add("Content-Type", "application/json")
return http.StatusOK, payload, nil
}

View file

@ -7,9 +7,14 @@ import (
const StaticRoute = "/static/"
type StaticController struct {
staticDir string
}
func NewStaticController(staticDir string) *StaticController {
return &StaticController{staticDir: staticDir}
}
func (sc *StaticController) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fs := http.FileServer(http.Dir("./static"))
fs := http.FileServer(http.Dir(sc.staticDir))
http.StripPrefix(StaticRoute, fs).ServeHTTP(w, r)
}

View file

@ -7,6 +7,7 @@ import (
"io"
"net/http"
"path/filepath"
"sort"
"git.faercol.me/faercol/http-boot-server/bootserver/helpers"
"git.faercol.me/faercol/http-boot-server/bootserver/services"
@ -35,17 +36,19 @@ type templateData struct {
type UIController struct {
clientService *services.ClientHandlerService
l *logrus.Logger
staticDir string
}
func NewUIController(l *logrus.Logger, service *services.ClientHandlerService) *UIController {
func NewUIController(l *logrus.Logger, service *services.ClientHandlerService, staticDir string) *UIController {
return &UIController{
clientService: service,
l: l,
staticDir: staticDir,
}
}
func (uc *UIController) serveUI(w http.ResponseWriter, r *http.Request) (int, int, error) {
lp := filepath.Join("templates", "index.html")
lp := filepath.Join(uc.staticDir, "templates", "index.html")
tmpl, err := template.ParseFiles(lp)
if err != nil {
return http.StatusInternalServerError, -1, fmt.Errorf("failed to init template: %w", err)
@ -63,10 +66,13 @@ func (uc *UIController) serveUI(w http.ResponseWriter, r *http.Request) (int, in
for id, bo := range clt.Options {
tplBO = append(tplBO, templateBootOption{
Name: bo.Name,
Path: bo.Path,
Path: bo.DevicePath,
ID: id,
Selected: id == clt.SelectedOption,
})
sort.Slice(tplBO, func(i, j int) bool {
return tplBO[i].ID < tplBO[j].ID
})
}
dat.Clients = append(dat.Clients, templateClient{
ID: clt.ID.String(),
@ -87,10 +93,16 @@ func (uc *UIController) serveUI(w http.ResponseWriter, r *http.Request) (int, in
}
func (uc *UIController) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.RequestURI != "/" {
uc.l.Errorf("Unhandled route %q", r.RequestURI)
helpers.HandleResponse(w, r, http.StatusNotFound, nil, uc.l)
return
}
returncode, contentLength, err := uc.serveUI(w, r)
if err != nil {
uc.l.Errorf("Error serving UI: %s", err.Error())
helpers.HandleResponse(w, r, returncode, nil, uc.l)
} else {
helpers.AddToContext(r, returncode, contentLength)
}
helpers.AddToContext(r, returncode, contentLength)
}

View file

@ -0,0 +1,80 @@
package homeassistant
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"git.faercol.me/faercol/http-boot-server/bootserver/config"
"git.faercol.me/faercol/http-boot-server/bootserver/logger"
)
type Entity struct {
State string `json:"state"`
ID string `json:"-"`
Attributes map[string]string `json:"attributes,omitempty"`
}
func newBootOptionEntity(device, option string) Entity {
return Entity{
State: option,
ID: "httpboot." + device,
Attributes: nil,
}
}
type HomeAssistantExporter struct {
clt *http.Client
baseURL string
token string
}
func New(conf *config.AppConfig) *HomeAssistantExporter {
clt := http.Client{}
return &HomeAssistantExporter{
clt: &clt,
baseURL: conf.HomeAssistantHost,
token: conf.HomeAssistantToken,
}
}
func (e *HomeAssistantExporter) SendBootOption(ctx context.Context, device string, option string) error {
subCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
entity := newBootOptionEntity(device, option)
dat, err := json.Marshal(entity)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(subCtx, http.MethodPost, e.baseURL+"/api/states/"+entity.ID, bytes.NewBuffer(dat))
if err != nil {
return err
}
req.Header.Add("Authorization", "Bearer "+e.token)
resp, err := e.clt.Do(req)
if err != nil {
return err
}
switch resp.StatusCode {
case http.StatusOK:
logger.L.Debugf("Updated boot info for device %s to %s", device, option)
case http.StatusCreated:
logger.L.Debugf("Created boot info for device %s with value %s", device, option)
default:
respBod, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return fmt.Errorf("unexpected returncode %d (%s)", resp.StatusCode, string(respBod))
}
return nil
}

View file

@ -8,6 +8,7 @@ import (
"time"
"git.faercol.me/faercol/http-boot-server/bootserver/config"
"git.faercol.me/faercol/http-boot-server/bootserver/homeassistant"
"git.faercol.me/faercol/http-boot-server/bootserver/logger"
"git.faercol.me/faercol/http-boot-server/bootserver/server"
"git.faercol.me/faercol/http-boot-server/bootserver/services"
@ -61,6 +62,21 @@ func main() {
logger.L.Fatalf("Failed to start UDP listener: %s", err.Error())
}
if conf.HomeAssistantEnabled {
logger.L.Info("Home assistant integration enabled, sending current configuration to the host")
haClt := homeassistant.New(conf)
cltSrv := services.NewClientHandlerService(conf.DataFilepath, logger.L)
clts, err := cltSrv.GetAllClientConfig()
if err != nil {
logger.L.Fatalf("Failed to get current clients from the storage: %s", err.Error())
}
for _, c := range clts {
if err := haClt.SendBootOption(context.Background(), c.Name, c.Options[c.SelectedOption].Name); err != nil {
logger.L.Errorf("Failed to send config to homeassistant: %s", err.Error())
}
}
}
go s.Run(mainCtx)
go listener.Run(mainCtx)

View file

@ -67,10 +67,11 @@ func New(appConf *config.AppConfig, logger *logrus.Logger) (*Server, error) {
}
service := services.NewClientHandlerService(appConf.DataFilepath, logger)
controllers := map[string]http.Handler{
client.EnrollRoute: middlewares.WithLogger(client.NewEnrollController(logger, service), logger),
client.ConfigRoute: middlewares.WithLogger(client.NewGetConfigController(logger, service, appConf), logger),
ui.StaticRoute: &ui.StaticController{},
ui.UIRoute: middlewares.WithLogger(ui.NewUIController(logger, service), logger),
client.EnrollRoute: middlewares.WithLogger(client.NewEnrollController(logger, service, appConf.UDPPort, appConf.UPDMcastGroup), logger),
client.ConfigRoute: middlewares.WithLogger(client.NewGetConfigController(logger, service, appConf), logger),
client.SetBootRoute: middlewares.WithLogger(client.NewBootController(logger, service, appConf), logger),
ui.StaticRoute: middlewares.WithLogger(ui.NewStaticController(appConf.StaticDir), logger),
ui.UIRoute: middlewares.WithLogger(ui.NewUIController(logger, service, appConf.StaticDir), logger),
}
m := http.NewServeMux()
@ -86,6 +87,7 @@ func New(appConf *config.AppConfig, logger *logrus.Logger) (*Server, error) {
address: addr,
clients: make(map[string]bootoption.Client),
controllers: controllers,
ctx: context.TODO(),
}, nil
}

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"sort"
"time"
"git.faercol.me/faercol/http-boot-server/bootserver/bootoption"
@ -111,6 +112,9 @@ func (chs *ClientHandlerService) GetAllClientConfig() ([]*bootoption.Client, err
clt.ID = id
clientList = append(clientList, clt)
}
sort.Slice(clientList, func(i, j int) bool {
return clientList[i].ID.String() < clientList[j].ID.String()
})
return clientList, nil
}
@ -152,6 +156,20 @@ func (chs *ClientHandlerService) GetClientSelectedBootOption(client uuid.UUID) (
}
}
func (chs *ClientHandlerService) DeleteClient(client uuid.UUID) error {
var err error
clients, err := chs.load()
if err != nil {
return fmt.Errorf("failed to load current config: %w", err)
}
delete(clients, client)
if err := chs.unload(clients); err != nil {
return fmt.Errorf("failed to save current config: %w", err)
}
return nil
}
func (chs *ClientHandlerService) SetClientBootOption(client uuid.UUID, option string) error {
var err error

View file

@ -0,0 +1,20 @@
function selectBootOption(clientID, bootID) {
const Http = new XMLHttpRequest;
var host = window.location.protocol + "//" + window.location.host;
const url = host + "/config/boot"
console.debug(`Sending request to ${url}`);
Http.open("PUT", url);
Http.setRequestHeader("Content-Type", "application/json");
const body = JSON.stringify({
client_id: clientID,
option_id: bootID,
});
Http.onload = () => {
if (Http.readyState === 4 && Http.status === 202) {
location.reload();
} else {
console.error(`Unexpected returncode ${Http.status}`)
}
};
Http.send(body);
}

View file

@ -1,31 +1,108 @@
:root {
/* Base colours */
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
/* Surface colours */
--surface0: #313244;
--surface1: #45475a;
--surface2: #585b70;
--overlay0: #6c7086;
--overlay1: #7f849c;
--overlay2: #9399b2;
/* Text colours */
--text: #cdd6f4;
--subtext0: #a6adc8;
--subtext1: #bac2de;
/* Main colours */
--rosewater: #f5e0dc;
--flamingo: #f2cdcd;
--pink: #f5c2e7;
--mauve: #cba6f7;
--red: #f38ba8;
--maroon: #eba0ac;
--peach: #fab387;
--yellow: #f9e2af;
--green: #a6e3a1;
--teal: #94e2d5;
--sky: #89dceb;
--sapphire: #74c7ec;
--blue: #89b4fa;
--lavender: #b4befe;
}
body {
background-color: black;
color: white;
background-color: var(--crust);
color: var(--text);
}
.page-content {
max-width: 1500px;
margin: auto;
}
.container {
border-radius: 20px;
background-color: var(--base);
padding: 5px 10px;
margin: 5px auto;
}
.header-grid {
display: grid;
grid-template-columns: 1fr 3fr;
align-items: stretch;
justify-items: stretch;
column-gap: 10px;
margin: 5px auto;
.container {
padding: 10px;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
}
.client-list-grid {
display: grid;
grid-template-columns: 1fr;
justify-items: stretch;
margin: 5px auto;
row-gap: 5px;
.container {
margin: 0;
}
}
.stat-container {
h2 {
margin: auto;
width: 100%;
vertical-align: middle;
}
}
.main-container {
border-radius: 5px;
background-color: #121212;
padding: 20px;
}
.title-container {
border-radius: 5px 5px 0px 0px;
padding: 5px;
background-color: #1A3A5D;
}
color: var(--blue);
.client-list-container {
border-radius: 0px 0px 5px 5px;
padding: 10px;
background-color: #232323;
}
.client-container {
border-radius: 5px;
padding: 4px;
margin-top: 2px;
margin-bottom: 2px;
h1 {
margin: auto;
padding: auto;
vertical-align: middle;
width: 100%;
}
}
.client-header {
@ -36,7 +113,7 @@ body {
.client-uuid {
font-size: smaller;
color: #9B9B9B;
color: var(--subtext1);
}
}
@ -48,9 +125,16 @@ body {
.client-boot-option {
margin: 3px auto;
padding: 8px 8px;
cursor: pointer;
&.selected {
background-color: #3E4042;
background-color: var(--overlay0);
cursor: default;
}
&:hover {
background-color: var(--overlay2);
--text-variant: #000000;
}
}
@ -61,7 +145,7 @@ body {
.boot-option-uuid {
font-size: smaller;
color: #9B9B9B;
color: var(--subtext0);
}
.boot-option-path {

View file

@ -5,28 +5,36 @@
<meta charset="utf-8">
<title>HTTP boot server</title>
<link rel="stylesheet" href="/static/stylesheets/main.css">
<script src="/static/scripts/index.js"></script>
</head>
<body>
<div class="main-container">
<div class="page-content">
<div class="header-grid">
<div class="stat-container container">
<h2>{{len .Clients}} enrolled clients</h2>
</div>
<div class="title-container container">
<h1>HTTP boot server admin page</h1>
</div>
<div class="title-container">
<h1>HTTP boot server admin page</h1>
</div>
<div class="client-list-container">
<h2>Currently enrolled clients</h2>
<div class="client-list-grid">
{{range .Clients}}
<div class="client-container">
<div class="client-container container">
<div class="client-header">
<span class="client-name">{{.Name}}</span>
<span class="client-uuid">{{.ID}}</span>
</div>
<div class="client-content">
<div class="client-boot-options">
{{range .BootOptions}}
<div class="client-boot-option{{if .Selected}} selected{{end}}">
{{$cid := .ID}}{{range .BootOptions}}
<div class="client-boot-option{{if .Selected}} selected{{end}}"
onclick="selectBootOption('{{$cid}}', '{{.ID}}')">
<div>
<span class="boot-option-name">{{.Name}}</span>
<span class="boot-option-uuid">{{.ID}}</span>

View file

@ -3,6 +3,7 @@ package udplistener
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
@ -20,14 +21,28 @@ type udpMessage struct {
message bootprotocol.Message
}
type discoveryPayload struct {
ManagementAddress string `json:"managementAddress"`
Version string `json:"version"`
}
func payloadFromConfig(conf config.AppConfig) discoveryPayload {
return discoveryPayload{
ManagementAddress: conf.PublicHost,
Version: "1",
}
}
type UDPListener struct {
addr *net.UDPAddr
laddr *net.UDPAddr
iface *net.Interface
l *net.UDPConn
log *logrus.Logger
ctx context.Context
service *services.ClientHandlerService
cancel context.CancelFunc
conf *config.AppConfig
}
func New(conf *config.AppConfig, log *logrus.Logger) (*UDPListener, error) {
@ -41,12 +56,19 @@ func New(conf *config.AppConfig, log *logrus.Logger) (*UDPListener, error) {
return nil, fmt.Errorf("failed to resolve interface name %s: %w", conf.UDPIface, err)
}
laddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("[%s%%%s]:0", conf.UDPSrcAddr, conf.UDPIface))
if err != nil {
return nil, fmt.Errorf("failed to resolve UDP source address: %w", err)
}
return &UDPListener{
addr: addr,
iface: iface,
laddr: laddr,
ctx: context.TODO(),
service: services.NewClientHandlerService(conf.DataFilepath, log),
log: log,
conf: conf,
}, nil
}
@ -78,7 +100,7 @@ func (l *UDPListener) handleBootRequest(msg bootprotocol.Message, subLogger logr
requestLogger.Errorf("Failed to get config for client: %s", err.Error())
return bootprotocol.Deny(msg.ID(), "unknown server error")
}
return bootprotocol.Accept(msg.ID(), bootOption.Path)
return bootprotocol.Accept(msg.ID(), bootOption.DevicePath)
}
func (l *UDPListener) handleClient(msg *udpMessage) error {
@ -88,7 +110,7 @@ func (l *UDPListener) handleClient(msg *udpMessage) error {
response := l.handleBootRequest(msg.message, clientLogger)
clientLogger.Debug("Dialing client for answer")
con, err := net.DialUDP("udp", nil, msg.sourceAddr)
con, err := net.DialUDP("udp", l.laddr, msg.sourceAddr)
if err != nil {
return fmt.Errorf("failed to dialed client: %w", err)
}
@ -110,6 +132,33 @@ func (l *UDPListener) handleClient(msg *udpMessage) error {
return nil
}
func (l *UDPListener) handleDiscovery(src *net.UDPAddr) error {
clientLogger := l.log.WithField("clientIP", src.IP)
clientLogger.Debug("Dialing client for answer")
con, err := net.DialUDP("udp", l.laddr, src)
if err != nil {
return fmt.Errorf("failed to dial client: %w", err)
}
defer con.Close()
clientLogger.Debug("Sending response to client")
response := payloadFromConfig(*l.conf)
dat, err := json.Marshal(response)
if err != nil {
return fmt.Errorf("failed to marshal response to json, %w", err)
}
n, err := con.Write(dat)
if err != nil {
return fmt.Errorf("failed to send response to client, %w", err)
}
if n != len(dat) {
return fmt.Errorf("failed to send the entire response to client (%d/%d)", n, len(dat))
}
return nil
}
func (l *UDPListener) listen() (*udpMessage, error) {
buffer := make([]byte, bufferLength)
n, source, err := l.l.ReadFromUDP(buffer)
@ -131,6 +180,7 @@ func (l *UDPListener) listen() (*udpMessage, error) {
func (l *UDPListener) mainLoop() {
msgChan := make(chan *udpMessage, 10)
discoveryChan := make(chan *net.UDPAddr, 10)
errChan := make(chan error, 10)
for {
@ -140,7 +190,11 @@ func (l *UDPListener) mainLoop() {
if err != nil {
errChan <- fmt.Errorf("error while listening to UDP packets: %w", err)
} else {
msgChan <- msg
if msg.message.Action() == bootprotocol.ActionDiscover {
discoveryChan <- msg.sourceAddr
} else {
msgChan <- msg
}
}
}()
@ -158,6 +212,10 @@ func (l *UDPListener) mainLoop() {
if err := l.handleClient(msg); err != nil {
l.log.Errorf("Failed to handle message from client: %q", err.Error())
}
case src := <-discoveryChan:
if err := l.handleDiscovery(src); err != nil {
l.log.Errorf("Failed to handle discovery message: %q", err.Error())
}
}
}