164 lines
4.8 KiB
Go
164 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
"time"
|
|
|
|
"nodedc.local/mission-core/node-agent/internal/node"
|
|
"nodedc.local/mission-core/node-agent/web"
|
|
)
|
|
|
|
var version = "0.2.0"
|
|
|
|
const defaultSocket = "/run/mission-core-node/admin.sock"
|
|
|
|
func main() {
|
|
if err := run(); err != nil {
|
|
log.Print(err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func run() error {
|
|
if len(os.Args) > 1 && os.Args[1] == "authorize" {
|
|
return authorize(defaultSocket)
|
|
}
|
|
if len(os.Args) == 3 && os.Args[1] == "ssh-keys" {
|
|
value, err := node.AuthorizedKeys("/var/lib/mission-core-node/ssh-keys.json", os.Args[2])
|
|
if err == nil {
|
|
fmt.Print(value)
|
|
}
|
|
return err
|
|
}
|
|
flags := flag.NewFlagSet("node-agent", flag.ContinueOnError)
|
|
dir := flags.String("state", "/var/lib/mission-core-node", "private state directory")
|
|
socket := flags.String("socket", defaultSocket, "private launcher socket")
|
|
listen := flags.String("listen", "127.0.0.1:8780", "loopback UI address")
|
|
if err := flags.Parse(os.Args[1:]); err != nil {
|
|
return err
|
|
}
|
|
host, _, err := net.SplitHostPort(*listen)
|
|
if err != nil || host != "127.0.0.1" {
|
|
return errors.New("local UI must bind 127.0.0.1")
|
|
}
|
|
// Bind before touching state/socket; a second instance cannot replace identity or launcher authority.
|
|
tcp, err := net.Listen("tcp", *listen)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tcp.Close()
|
|
store, err := node.OpenStore(*dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
assets, err := fs.Sub(web.Assets, "dist")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
app := &node.Server{Store: store, Assets: assets, Origin: "http://" + *listen, Version: version, Inventory: func() node.Inventory { return node.Host("/") }}
|
|
pairing, err := node.OpenPairing(store, *dir, version, app.Inventory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
app.Pairing = pairing
|
|
nodeID, _ := store.Public()
|
|
app.Sensors, err = node.OpenSensors(*dir, nodeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pairing.Sensors = app.Sensors
|
|
app.DeviceEnrollment, err = node.OpenDeviceEnrollment(*dir, nodeID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pairing.DeviceEnrollment = app.DeviceEnrollment
|
|
app.Sensors.NetworkDevices = app.DeviceEnrollment
|
|
app.Access = &node.AccessStore{Path: filepath.Join(*dir, "ssh-keys.json"), Users: func() []string { return node.LocalAdmins("/") }}
|
|
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
|
|
return err
|
|
}
|
|
if info, e := os.Lstat(*socket); e == nil {
|
|
if info.Mode()&os.ModeSocket == 0 {
|
|
return errors.New("launcher path is not a socket")
|
|
}
|
|
if err := os.Remove(*socket); err != nil {
|
|
return err
|
|
}
|
|
} else if !os.IsNotExist(e) {
|
|
return e
|
|
}
|
|
unix, err := net.Listen("unix", *socket)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unix.Close()
|
|
defer os.Remove(*socket)
|
|
if err := os.Chmod(*socket, 0600); err != nil {
|
|
return err
|
|
}
|
|
admin := http.NewServeMux()
|
|
admin.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"url": app.IssueLogin()})
|
|
})
|
|
public := &http.Server{Handler: app.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 8192}
|
|
private := &http.Server{Handler: admin, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 10 * time.Second, MaxHeaderBytes: 8192}
|
|
errs := make(chan error, 2)
|
|
go func() { errs <- public.Serve(tcp) }()
|
|
go func() { errs <- private.Serve(unix) }()
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
go app.Sensors.WatchUSB(ctx)
|
|
go pairing.Run(ctx)
|
|
log.Print("Mission Core Node " + version + " listening on loopback")
|
|
select {
|
|
case err = <-errs:
|
|
case <-ctx.Done():
|
|
}
|
|
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
public.Shutdown(shutdown)
|
|
private.Shutdown(shutdown)
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func authorize(socket string) error {
|
|
// Called by a fixed, root-owned polkit helper. No user-supplied URL, command,
|
|
// path, or environment is interpreted by the privileged operation.
|
|
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
|
}}}
|
|
res, err := client.Post("http://local/login", "application/json", nil)
|
|
if err != nil {
|
|
return errors.New("Node is unavailable")
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != 200 {
|
|
return errors.New("Node rejected local authorization")
|
|
}
|
|
var value struct {
|
|
URL string `json:"url"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(res.Body, 1024)).Decode(&value); err != nil {
|
|
return err
|
|
}
|
|
fmt.Print(value.URL)
|
|
return nil
|
|
}
|