This commit is contained in:
parent
576c78e6dd
commit
0b6bbce7d5
9 changed files with 246 additions and 84 deletions
|
@ -3,6 +3,9 @@
|
||||||
build:
|
build:
|
||||||
go build -o build/
|
go build -o build/
|
||||||
|
|
||||||
|
buildarm:
|
||||||
|
env GOOS=linux GOARCH=arm64 go build -o build/bootserver_arm
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -v ./...
|
go test -v ./...
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package bootoption
|
package bootoption
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
type EFIApp struct {
|
type EFIApp struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
@ -7,6 +9,7 @@ type EFIApp struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
|
ID uuid.UUID
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Options []EFIApp `json:"options"`
|
Options []EFIApp `json:"options"`
|
||||||
|
|
|
@ -64,6 +64,7 @@ type Message interface {
|
||||||
encoding.BinaryMarshaler
|
encoding.BinaryMarshaler
|
||||||
Action() Action
|
Action() Action
|
||||||
ID() uuid.UUID
|
ID() uuid.UUID
|
||||||
|
String() string
|
||||||
}
|
}
|
||||||
|
|
||||||
type requestMessage struct {
|
type requestMessage struct {
|
||||||
|
@ -103,6 +104,10 @@ func (rm *requestMessage) ID() uuid.UUID {
|
||||||
return rm.id
|
return rm.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rm *requestMessage) String() string {
|
||||||
|
return fmt.Sprintf("%s from %s", ActionRequest.String(), rm.ID().String())
|
||||||
|
}
|
||||||
|
|
||||||
type acceptMessage struct {
|
type acceptMessage struct {
|
||||||
id uuid.UUID
|
id uuid.UUID
|
||||||
efiApp string
|
efiApp string
|
||||||
|
@ -151,6 +156,10 @@ func (am *acceptMessage) ID() uuid.UUID {
|
||||||
return am.id
|
return am.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (am *acceptMessage) String() string {
|
||||||
|
return fmt.Sprintf("%s from %s, app %s", ActionAccept.String(), am.ID().String(), am.efiApp)
|
||||||
|
}
|
||||||
|
|
||||||
type denyMessage struct {
|
type denyMessage struct {
|
||||||
id uuid.UUID
|
id uuid.UUID
|
||||||
reason string
|
reason string
|
||||||
|
@ -199,6 +208,10 @@ func (dm *denyMessage) ID() uuid.UUID {
|
||||||
return dm.id
|
return dm.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (dm *denyMessage) String() string {
|
||||||
|
return fmt.Sprintf("%s from %s, reason %q", ActionDeny.String(), dm.ID().String(), dm.reason)
|
||||||
|
}
|
||||||
|
|
||||||
func MessageFromBytes(dat []byte) (Message, error) {
|
func MessageFromBytes(dat []byte) (Message, error) {
|
||||||
rawAction, content, found := bytes.Cut(dat, spaceByte)
|
rawAction, content, found := bytes.Cut(dat, spaceByte)
|
||||||
if !found {
|
if !found {
|
||||||
|
|
|
@ -48,14 +48,22 @@ type jsonConf struct {
|
||||||
Mode string `json:"mode"`
|
Mode string `json:"mode"`
|
||||||
SockPath string `json:"sock"`
|
SockPath string `json:"sock"`
|
||||||
} `json:"server"`
|
} `json:"server"`
|
||||||
|
BootProvider struct {
|
||||||
|
Iface string `json:"interface"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
McastGroup string `json:"multicast_group"`
|
||||||
|
} `json:"boot_provider"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
LogLevel logrus.Level
|
LogLevel logrus.Level
|
||||||
ServerMode ListeningMode
|
ServerMode ListeningMode
|
||||||
Host string
|
Host string
|
||||||
Port int
|
Port int
|
||||||
SockPath string
|
SockPath string
|
||||||
|
UPDMcastGroup string
|
||||||
|
UDPPort int
|
||||||
|
UDPIface string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseLevel(lvlStr string) logrus.Level {
|
func parseLevel(lvlStr string) logrus.Level {
|
||||||
|
@ -82,14 +90,20 @@ func (ac *AppConfig) UnmarshalJSON(data []byte) error {
|
||||||
ac.SockPath = jsonConf.Server.SockPath
|
ac.SockPath = jsonConf.Server.SockPath
|
||||||
ac.Host = jsonConf.Server.Host
|
ac.Host = jsonConf.Server.Host
|
||||||
ac.Port = jsonConf.Server.Port
|
ac.Port = jsonConf.Server.Port
|
||||||
|
ac.UPDMcastGroup = jsonConf.BootProvider.McastGroup
|
||||||
|
ac.UDPIface = jsonConf.BootProvider.Iface
|
||||||
|
ac.UDPPort = jsonConf.BootProvider.Port
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultConfig AppConfig = AppConfig{
|
var defaultConfig AppConfig = AppConfig{
|
||||||
LogLevel: logrus.InfoLevel,
|
LogLevel: logrus.InfoLevel,
|
||||||
ServerMode: ModeNet,
|
ServerMode: ModeNet,
|
||||||
Host: "0.0.0.0",
|
Host: "0.0.0.0",
|
||||||
Port: 5000,
|
Port: 5000,
|
||||||
|
UPDMcastGroup: "ff02::abcd:1234",
|
||||||
|
UDPPort: 42,
|
||||||
|
UDPIface: "eth0",
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(filepath string) (*AppConfig, error) {
|
func New(filepath string) (*AppConfig, error) {
|
||||||
|
|
|
@ -1,85 +1,85 @@
|
||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
// import (
|
||||||
"encoding/json"
|
// "encoding/json"
|
||||||
"fmt"
|
// "fmt"
|
||||||
"io"
|
// "io"
|
||||||
"net"
|
// "net"
|
||||||
"net/http"
|
// "net/http"
|
||||||
|
|
||||||
"git.faercol.me/faercol/http-boot-server/bootserver/helpers"
|
// "git.faercol.me/faercol/http-boot-server/bootserver/helpers"
|
||||||
"git.faercol.me/faercol/http-boot-server/bootserver/services"
|
// "git.faercol.me/faercol/http-boot-server/bootserver/services"
|
||||||
"github.com/sirupsen/logrus"
|
// "github.com/sirupsen/logrus"
|
||||||
)
|
// )
|
||||||
|
|
||||||
const BootRoute = "/boot"
|
// const BootRoute = "/boot"
|
||||||
|
|
||||||
type BootController struct {
|
// type BootController struct {
|
||||||
clientService *services.ClientHandlerService
|
// clientService *services.ClientHandlerService
|
||||||
l *logrus.Logger
|
// l *logrus.Logger
|
||||||
}
|
// }
|
||||||
|
|
||||||
func NewBootController(logger *logrus.Logger, service *services.ClientHandlerService) *BootController {
|
// func NewBootController(logger *logrus.Logger, service *services.ClientHandlerService) *BootController {
|
||||||
return &BootController{
|
// return &BootController{
|
||||||
clientService: service,
|
// clientService: service,
|
||||||
l: logger,
|
// l: logger,
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
func (bc *BootController) getBootOption(clientIP string, w http.ResponseWriter, r *http.Request) (int, []byte, error) {
|
// func (bc *BootController) getBootOption(clientIP string, w http.ResponseWriter, r *http.Request) (int, []byte, error) {
|
||||||
bootOption, err := bc.clientService.GetClientSelectedBootOption(clientIP)
|
// bootOption, err := bc.clientService.GetClientSelectedBootOption(clientIP)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
return http.StatusInternalServerError, nil, fmt.Errorf("failed to get boot option: %w", err)
|
// return http.StatusInternalServerError, nil, fmt.Errorf("failed to get boot option: %w", err)
|
||||||
}
|
// }
|
||||||
|
|
||||||
dat, err := json.Marshal(bootOption)
|
// dat, err := json.Marshal(bootOption)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
return http.StatusInternalServerError, nil, fmt.Errorf("failed to serialize body")
|
// return http.StatusInternalServerError, nil, fmt.Errorf("failed to serialize body")
|
||||||
}
|
// }
|
||||||
|
|
||||||
w.Header().Add("Content-Type", "application/json")
|
// w.Header().Add("Content-Type", "application/json")
|
||||||
return http.StatusOK, dat, nil
|
// return http.StatusOK, dat, nil
|
||||||
}
|
// }
|
||||||
|
|
||||||
func (bc *BootController) setBootOption(clientIP string, w http.ResponseWriter, r *http.Request) (int, error) {
|
// func (bc *BootController) setBootOption(clientIP string, w http.ResponseWriter, r *http.Request) (int, error) {
|
||||||
dat, err := io.ReadAll(r.Body)
|
// dat, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
return http.StatusInternalServerError, fmt.Errorf("failed to read body: %w", err)
|
// return http.StatusInternalServerError, fmt.Errorf("failed to read body: %w", err)
|
||||||
}
|
// }
|
||||||
var option string
|
// var option string
|
||||||
if err := json.Unmarshal(dat, &option); err != nil {
|
// if err := json.Unmarshal(dat, &option); err != nil {
|
||||||
return http.StatusInternalServerError, fmt.Errorf("failed to parse body: %w", err)
|
// return http.StatusInternalServerError, fmt.Errorf("failed to parse body: %w", err)
|
||||||
}
|
// }
|
||||||
|
|
||||||
if err := bc.clientService.SetClientBootOption(clientIP, option); err != nil {
|
// if err := bc.clientService.SetClientBootOption(clientIP, option); err != nil {
|
||||||
return http.StatusInternalServerError, fmt.Errorf("failed to set boot option for client: %w", err)
|
// return http.StatusInternalServerError, fmt.Errorf("failed to set boot option for client: %w", err)
|
||||||
}
|
// }
|
||||||
|
|
||||||
return http.StatusAccepted, nil
|
// return http.StatusAccepted, nil
|
||||||
}
|
// }
|
||||||
|
|
||||||
func (bc *BootController) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
// func (bc *BootController) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
// clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
bc.l.Errorf("Failed to read remote IP: %s", err.Error())
|
// bc.l.Errorf("Failed to read remote IP: %s", err.Error())
|
||||||
helpers.HandleResponse(w, r, http.StatusInternalServerError, nil, bc.l)
|
// helpers.HandleResponse(w, r, http.StatusInternalServerError, nil, bc.l)
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
|
|
||||||
var returncode int
|
// var returncode int
|
||||||
var content []byte
|
// var content []byte
|
||||||
|
|
||||||
switch r.Method {
|
// switch r.Method {
|
||||||
case http.MethodGet:
|
// case http.MethodGet:
|
||||||
returncode, content, err = bc.getBootOption(clientIP, w, r)
|
// returncode, content, err = bc.getBootOption(clientIP, w, r)
|
||||||
case http.MethodPut:
|
// case http.MethodPut:
|
||||||
returncode, err = bc.setBootOption(clientIP, w, r)
|
// returncode, err = bc.setBootOption(clientIP, w, r)
|
||||||
default:
|
// default:
|
||||||
returncode = http.StatusMethodNotAllowed
|
// returncode = http.StatusMethodNotAllowed
|
||||||
}
|
// }
|
||||||
|
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
bc.l.Errorf("An error occured while handling boot request: %q", err.Error())
|
// bc.l.Errorf("An error occured while handling boot request: %q", err.Error())
|
||||||
}
|
// }
|
||||||
helpers.HandleResponse(w, r, returncode, content, bc.l)
|
// helpers.HandleResponse(w, r, returncode, content, bc.l)
|
||||||
}
|
// }
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"git.faercol.me/faercol/http-boot-server/bootserver/config"
|
"git.faercol.me/faercol/http-boot-server/bootserver/config"
|
||||||
"git.faercol.me/faercol/http-boot-server/bootserver/logger"
|
"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/server"
|
||||||
|
"git.faercol.me/faercol/http-boot-server/bootserver/udplistener"
|
||||||
)
|
)
|
||||||
|
|
||||||
const stopTimeout = 10 * time.Second
|
const stopTimeout = 10 * time.Second
|
||||||
|
@ -47,7 +48,17 @@ func main() {
|
||||||
logger.L.Fatalf("Failed to initialize server: %s", err.Error())
|
logger.L.Fatalf("Failed to initialize server: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.L.Info("Initializing UDP listener")
|
||||||
|
listener, err := udplistener.New(conf.UDPIface, conf.UPDMcastGroup, conf.UDPPort, logger.L)
|
||||||
|
if err != nil {
|
||||||
|
logger.L.Fatalf("Failed to initialize UDP listener: %s", err.Error())
|
||||||
|
}
|
||||||
|
if err := listener.Init(); err != nil {
|
||||||
|
logger.L.Fatalf("Failed to start UDP listener: %s", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
go s.Run(mainCtx)
|
go s.Run(mainCtx)
|
||||||
|
go listener.Run(mainCtx)
|
||||||
|
|
||||||
c := make(chan os.Signal, 1)
|
c := make(chan os.Signal, 1)
|
||||||
signal.Notify(c, os.Interrupt)
|
signal.Notify(c, os.Interrupt)
|
||||||
|
|
|
@ -66,7 +66,7 @@ func New(appConf *config.AppConfig, logger *logrus.Logger) (*Server, error) {
|
||||||
}
|
}
|
||||||
service := services.NewClientHandlerService()
|
service := services.NewClientHandlerService()
|
||||||
controllers := map[string]http.Handler{
|
controllers := map[string]http.Handler{
|
||||||
client.BootRoute: middlewares.WithLogger(client.NewBootController(logger, service), logger),
|
// client.BootRoute: middlewares.WithLogger(client.NewBootController(logger, service), logger),
|
||||||
client.EnrollRoute: middlewares.WithLogger(client.NewEnrollController(logger, service), logger),
|
client.EnrollRoute: middlewares.WithLogger(client.NewEnrollController(logger, service), logger),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"git.faercol.me/faercol/http-boot-server/bootserver/bootoption"
|
"git.faercol.me/faercol/http-boot-server/bootserver/bootoption"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrUnknownClient = errors.New("unknown client")
|
var ErrUnknownClient = errors.New("unknown client")
|
||||||
|
@ -11,20 +12,20 @@ var ErrUnselectedBootOption = errors.New("unselected boot option")
|
||||||
var ErrUnknownBootOption = errors.New("unknown boot option")
|
var ErrUnknownBootOption = errors.New("unknown boot option")
|
||||||
|
|
||||||
type ClientHandlerService struct {
|
type ClientHandlerService struct {
|
||||||
clients map[string]*bootoption.Client
|
clients map[uuid.UUID]*bootoption.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClientHandlerService() *ClientHandlerService {
|
func NewClientHandlerService() *ClientHandlerService {
|
||||||
return &ClientHandlerService{
|
return &ClientHandlerService{
|
||||||
clients: make(map[string]*bootoption.Client),
|
clients: make(map[uuid.UUID]*bootoption.Client),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (chs *ClientHandlerService) AddClient(client *bootoption.Client) {
|
func (chs *ClientHandlerService) AddClient(client *bootoption.Client) {
|
||||||
chs.clients[client.IP] = client
|
chs.clients[client.ID] = client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (chs *ClientHandlerService) GetClientSelectedBootOption(client string) (*bootoption.EFIApp, error) {
|
func (chs *ClientHandlerService) GetClientSelectedBootOption(client uuid.UUID) (*bootoption.EFIApp, error) {
|
||||||
clientDetails, ok := chs.clients[client]
|
clientDetails, ok := chs.clients[client]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ErrUnknownClient
|
return nil, ErrUnknownClient
|
||||||
|
@ -42,7 +43,7 @@ func (chs *ClientHandlerService) GetClientSelectedBootOption(client string) (*bo
|
||||||
return nil, ErrUnknownBootOption
|
return nil, ErrUnknownBootOption
|
||||||
}
|
}
|
||||||
|
|
||||||
func (chs *ClientHandlerService) SetClientBootOption(client, option string) error {
|
func (chs *ClientHandlerService) SetClientBootOption(client uuid.UUID, option string) error {
|
||||||
clientDetails, ok := chs.clients[client]
|
clientDetails, ok := chs.clients[client]
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrUnknownClient
|
return ErrUnknownClient
|
||||||
|
|
117
bootserver/udplistener/udplistener.go
Normal file
117
bootserver/udplistener/udplistener.go
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
package udplistener
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"git.faercol.me/faercol/http-boot-server/bootserver/bootprotocol"
|
||||||
|
"git.faercol.me/faercol/http-boot-server/bootserver/services"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bufferLength = 2048
|
||||||
|
|
||||||
|
type udpMessage struct {
|
||||||
|
sourceAddr *net.UDPAddr
|
||||||
|
message bootprotocol.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
type UDPListener struct {
|
||||||
|
addr *net.UDPAddr
|
||||||
|
iface *net.Interface
|
||||||
|
l *net.UDPConn
|
||||||
|
log *logrus.Logger
|
||||||
|
ctx context.Context
|
||||||
|
service *services.ClientHandlerService
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(ifaceName, multicastGroup string, port int, log *logrus.Logger) (*UDPListener, error) {
|
||||||
|
addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("[%s]:%d", multicastGroup, port))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve UDP address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
iface, err := net.InterfaceByName(ifaceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve interface name %s: %w", ifaceName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UDPListener{
|
||||||
|
addr: addr,
|
||||||
|
iface: iface,
|
||||||
|
ctx: context.TODO(),
|
||||||
|
log: log,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *UDPListener) Init() error {
|
||||||
|
l.log.Debugf("Creating listener on address %s, iface %s", l.addr.String(), l.iface.Name)
|
||||||
|
listener, err := net.ListenMulticastUDP("udp", l.iface, l.addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to init listener: %w", err)
|
||||||
|
}
|
||||||
|
l.l = listener
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *UDPListener) listen() (*udpMessage, error) {
|
||||||
|
buffer := make([]byte, bufferLength)
|
||||||
|
n, source, err := l.l.ReadFromUDP(buffer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read UDP packet: %w", err)
|
||||||
|
}
|
||||||
|
if n > bufferLength {
|
||||||
|
return nil, fmt.Errorf("UDP packet too big (%d/%d)", n, bufferLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedMsg, err := bootprotocol.MessageFromBytes(bytes.Trim(buffer, "\x00"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &udpMessage{sourceAddr: source, message: parsedMsg}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *UDPListener) mainLoop() {
|
||||||
|
msgChan := make(chan *udpMessage, 10)
|
||||||
|
errChan := make(chan error, 10)
|
||||||
|
|
||||||
|
for {
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
msg, err := l.listen()
|
||||||
|
if err != nil {
|
||||||
|
errChan <- fmt.Errorf("error while listening to UDP packets: %w", err)
|
||||||
|
} else {
|
||||||
|
msgChan <- msg
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
l.log.Debug("Waiting for packets")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-l.ctx.Done():
|
||||||
|
if err := l.l.Close(); err != nil {
|
||||||
|
l.log.Errorf("Error closing UDP listener: %s", err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case err := <-errChan:
|
||||||
|
l.log.Error(err)
|
||||||
|
case msg := <-msgChan:
|
||||||
|
l.log.Infof("Request from %s: %q", msg.sourceAddr.String(), msg.message.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *UDPListener) Run(ctx context.Context) {
|
||||||
|
l.ctx, l.cancel = context.WithCancel(ctx)
|
||||||
|
l.mainLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *UDPListener) Cancel() {
|
||||||
|
l.cancel()
|
||||||
|
}
|
Loading…
Reference in a new issue