2026-04-29

This commit is contained in:
2026-04-29 22:52:33 +08:00
parent e762cbdfe3
commit 3ffefa66c3
28 changed files with 1442 additions and 19 deletions

View File

@@ -4,17 +4,17 @@ import (
"fmt"
"os"
"gitea.sunhpc.com/kelvin/sunhpc"
"gitea.sunhpc.com/kelvin/sunhpc/internal/app/control"
)
func main() {
root := sunhpc.GetRootCommand()
root := control.GetRootCommand()
err := root.Execute()
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %s\n", err)
if sunhpc.DebugFlag {
if control.DebugFlag {
fmt.Printf("\nSTACK TRACE: %+v\n", err)
}
os.Exit(255)

11
go.mod
View File

@@ -1,5 +1,12 @@
module gitea.sunhpc.com/kelvin/sunhpc.git
module gitea.sunhpc.com/kelvin/sunhpc
go 1.25.8
require gitea.sunhpc.com/kelvin/sunhpc v0.0.0-20260428114043-53d56ec6e2ba // indirect
require github.com/spf13/cobra v1.10.2
require (
github.com/creasty/defaults v1.8.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

16
go.sum
View File

@@ -1,2 +1,14 @@
gitea.sunhpc.com/kelvin/sunhpc v0.0.0-20260428114043-53d56ec6e2ba h1:NKGusycgJgYD/ljVamRqkLm8+jqT2h09xVjZf7NBAqc=
gitea.sunhpc.com/kelvin/sunhpc v0.0.0-20260428114043-53d56ec6e2ba/go.mod h1:qQ5hwUVNDDEjeZRE35FzMpsobqly7h0tRcBGCcvzgrc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,19 @@
package clean
import (
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/clean"
"github.com/spf13/cobra"
)
func CobraRunE(vars *variables) func(cmd *cobra.Command, args []string) (err error) {
return func(cmd *cobra.Command, args []string) (err error) {
if err = clean.CleanOciBlobCacheDir(); err != nil {
return err
} else if err = clean.CleanOverlays(); err != nil {
return err
} else {
return nil
}
}
}

View File

@@ -0,0 +1,23 @@
package clean
import (
"github.com/spf13/cobra"
"gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/completions"
)
type variables struct {
}
func GetCommand() *cobra.Command {
vars := variables{}
baseCmd := &cobra.Command{
DisableFlagsInUseLine: true,
Use: "clean",
Short: "Clean up",
Long: "This command cleans the OCI cache and removes leftovers from deleted nodes",
RunE: CobraRunE(&vars),
Args: cobra.NoArgs,
ValidArgsFunction: completions.None,
}
return baseCmd
}

View File

@@ -0,0 +1,9 @@
package completions
import (
"github.com/spf13/cobra"
)
func None(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
}

View File

@@ -0,0 +1,10 @@
package dhcp
import (
"github.com/spf13/cobra"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/configure"
)
func CobraRunE(cmd *cobra.Command, args []string) error {
return configure.DHCP()
}

View File

@@ -0,0 +1,24 @@
package dhcp
import (
"github.com/spf13/cobra"
"gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/completions"
)
var (
baseCmd = &cobra.Command{
DisableFlagsInUseLine: true,
Use: "dhcp [OPTIONS]",
Short: "Manage and initialize DHCP",
Long: "DHCP is a dependent service to Warewulf. This command will configure DHCP as defined\n" +
"in the warewulf.conf file.",
RunE: CobraRunE,
Args: cobra.NoArgs,
ValidArgsFunction: completions.None,
}
)
// GetRootCommand returns the root cobra.Command for the application.
func GetCommand() *cobra.Command {
return baseCmd
}

View File

@@ -0,0 +1,32 @@
package configure
import (
"github.com/spf13/cobra"
sunhpcconf "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/config"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/configure"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog"
)
func CobraRunE(cmd *cobra.Command, args []string) error {
var err error
conf := sunhpcconf.Get()
if conf.Autodetected() && conf.InitializedFromFile() {
if err = conf.PersistToFile(conf.GetSunhpcConf()); err != nil {
splog.Warn("error when persisting auto-detected settings: %s", err)
}
}
if allFunctions {
if _, err = configure.TLS(false); err != nil {
return err
}
err = configure.SUNHPCD()
if err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,30 @@
package configure
import (
"github.com/spf13/cobra"
"gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/configure/dhcp"
)
var (
baseCmd = &cobra.Command{
DisableFlagsInUseLine: true,
Use: "config [options]",
Short: "Manage sunhpc configuration",
Long: "This application allows you to manage and initialize Sunhpc configuration\n" +
"services based on the configuration in the sunhpc.conf file.",
RunE: CobraRunE,
Args: cobra.NoArgs,
}
allFunctions bool
)
func init() {
baseCmd.AddCommand(dhcp.GetCommand())
baseCmd.Flags().BoolVarP(&allFunctions, "all", "a", false, "Configure all services")
}
// GetRootCommand returns the root cobra.Command for the application.
func GetCommand() *cobra.Command {
return baseCmd
}

View File

@@ -4,7 +4,10 @@ import (
"os"
"github.com/spf13/cobra"
"gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/version"
"gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/clean"
"gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/configure"
sunhpcconf "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/config"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/help"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog"
)
@@ -16,18 +19,19 @@ var (
Short: "Sunhpc Control",
Long: `Control interface to the SunHPC Cluster Provisioning System.`,
PersistentPreRunE: rootPersistentPreRunE,
SilenceUsage: true,
SilenceErrors: true,
Args: cobra.NoArgs,
}
verboseArg bool
DebugFlag bool
SunhpcConfArg string
AllowEmptyConf bool
SilenceUsage: true,
SilenceErrors: true,
Args: cobra.NoArgs,
}
verboseArg bool
DebugFlag bool
LogLevel int
SunhpcConfArg string
AllowEmptyConf bool
)
func init() {
rootCmd.CompletionOptions.HiddenDefaultCmd = true
//rootCmd.CompletionOptions.HiddenDefaultCmd = true
rootCmd.PersistentFlags().BoolVarP(&verboseArg, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().BoolVarP(&DebugFlag, "debug", "d", false, "debug output")
rootCmd.PersistentFlags().IntVar(&LogLevel, "loglevel", splog.INFO, "Set log level to given string")
@@ -35,6 +39,42 @@ func init() {
rootCmd.PersistentFlags().StringVar(&SunhpcConfArg, "sunhpcconf", "", "Set the sunhpc configuration file")
rootCmd.PersistentFlags().BoolVar(&AllowEmptyConf, "emptyconf", false, "Allow empty configuration")
_ = rootCmd.PersistentFlags().MarkHidden("emptyconf")
rootCmd.SetUsageTemplate(help.UsageTemplate)
rootCmd.SetHelpTemplate(help.HelpTemplate)
rootCmd.AddCommand(clean.GetCommand())
rootCmd.AddCommand(configure.GetCommand())
}
func GetRootCommand() *cobra.Command {
return rootCmd
}
func rootPersistentPreRunE(cmd *cobra.Command, args []string) (err error) {
if DebugFlag {
splog.SetLogLevel(splog.DEBUG)
} else if verboseArg {
splog.SetLogLevel(splog.VERBOSE)
} else {
splog.SetLogLevel(splog.INFO)
}
if LogLevel != splog.INFO {
splog.SetLogLevel(LogLevel)
}
if SunhpcConfArg != "" {
sunhpcconf.ConfigFile = SunhpcConfArg
} else if os.Getenv("SUNHPC_CONF") != "" {
sunhpcconf.ConfigFile = os.Getenv("SUNHPC_CONF")
}
conf := sunhpcconf.Get()
if !AllowEmptyConf && !conf.InitializedFromFile() {
if err = conf.Read(sunhpcconf.ConfigFile, true); err != nil {
splog.Error("error reading configuration file: %s", err)
return
}
}
return
}

View File

@@ -0,0 +1,9 @@
package clean
func CleanOciBlobCacheDir() error {
return nil
}
func CleanOverlays() error {
return nil
}

View File

@@ -0,0 +1,56 @@
package config
import (
"net"
"github.com/creasty/defaults"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util"
)
type IPNet net.IPNet
func (n IPNet) MarshalText() ([]byte, error) {
ipnet := n.IPNet()
return []byte((&ipnet).String()), nil
}
func (n *IPNet) UnmarshalText(text []byte) error {
_, network, err := net.ParseCIDR(string(text))
if err != nil {
return err
}
*n = IPNet(*network)
return nil
}
func (n IPNet) IPNet() net.IPNet {
return net.IPNet(n)
}
type APIConf struct {
EnabledP *bool `yaml:"enabled,omitempty" default:"false"`
TLSEnabledP *bool `yaml:"tls,omitempty"`
AllowedNets []IPNet `yaml:"allowed subnets,omitempty" default:"[\"127.0.0.0/8\", \"::1/128\"]"`
}
func (conf *APIConf) AllowedIPNets() (allowedIPNets []net.IPNet) {
for _, allowedIPNet := range conf.AllowedNets {
allowedIPNets = append(allowedIPNets, allowedIPNet.IPNet())
}
return allowedIPNets
}
func (conf *APIConf) Unmarshal(unmarshal func(interface{}) error) error {
if err := defaults.Set(conf); err != nil {
return err
}
return nil
}
func (conf APIConf) Enabled() bool {
return util.BoolP(conf.EnabledP)
}
func (conf APIConf) TLSEnabled() bool {
return util.BoolP(conf.TLSEnabledP)
}

View File

@@ -0,0 +1,97 @@
package config
import (
"path"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util"
)
var ConfigFile = "/etc/sunhpc/sunhpc.conf"
type BuildConfig struct {
Bindir string `yaml:"bindir,omitempty" default:"@BINDIR@"`
Sysconfdir string `yaml:"sysconfdir,omitempty" default:"@SYSCONFDIR@"`
Localstatedir string `yaml:"localstatedir,omitempty" default:"@LOCALSTATEDIR@"`
Cachedir string `yaml:"cachedir,omitempty" default:"@CACHEDIR@"`
Ipxesource string `yaml:"ipxesource,omitempty" default:"@IPXESOURCE@"`
Srvdir string `yaml:"srvdir,omitempty" default:"@SRVDIR@"`
Firewallddir string `yaml:"firewallddir,omitempty" default:"@FIREWALLDDIR@"`
Systemddir string `yaml:"systemddir,omitempty" default:"@SYSTEMDDIR@"`
Datadir string `yaml:"datadir,omitempty" default:"@DATADIR@"`
WWOverlaydir string `yaml:"wwoverlaydir,omitempty" default:"@WWOVERLAYDIR@"`
WWChrootdir string `yaml:"wwchrootdir,omitempty" default:"@WWCHROOTDIR@"`
WWProvisiondir string `yaml:"wwprovisiondir,omitempty" default:"@WWPROVISIONDIR@"`
WWClientdir string `yaml:"wwclientdir,omitempty" default:"@WWCLIENTDIR@"`
}
const Version = "1.0.0"
const Release = "1"
type TFTPConf struct {
EnabledP *bool `yaml:"enabled" default:"true"`
TftpRoot string `yaml:"tftproot,omitempty" default:"@TFTPDIR@"`
SystemdName string `yaml:"systemd name,omitempty" default:"tftp"`
IpxeBinaries map[string]string `yaml:"ipxe,omitempty" default:"{\"00:09\": \"ipxe-snponly-x86_64.efi\",\"00:00\": \"undionly.kpxe\",\"00:0B\": \"arm64-efi/snponly.efi\",\"00:07\": \"ipxe-snponly-x86_64.efi\"}"`
}
func (conf TFTPConf) Enabled() bool {
return util.BoolP(conf.EnabledP)
}
// SunhpcConf adds additional Sunhpc-specific configuration to
// BaseConf.
type SunhpcConf struct {
Port int `yaml:"port,omitempty" default:"9873"`
TLSPort int `yaml:"tls port,omitempty" default:"9874"`
SecureP *bool `yaml:"secure,omitempty" default:"true"`
TLSEnabledP *bool `yaml:"tls,omitempty"`
UpdateInterval int `yaml:"update interval,omitempty" default:"60"`
AutobuildOverlaysP *bool `yaml:"autobuild overlays,omitempty" default:"true"`
EnableHostOverlayP *bool `yaml:"host overlay,omitempty" default:"true"`
GrubBootP *bool `yaml:"grubboot,omitempty" default:"false"`
SystemdName string `yaml:"systemd name,omitempty"`
}
func (conf SunhpcConf) Secure() bool {
return util.BoolP(conf.SecureP)
}
func (conf SunhpcConf) TLSEnabled() bool {
return util.BoolP(conf.TLSEnabledP)
}
func (conf SunhpcConf) AutobuildOverlays() bool {
return util.BoolP(conf.AutobuildOverlaysP)
}
func (conf SunhpcConf) EnableHostOverlay() bool {
return util.BoolP(conf.EnableHostOverlayP)
}
func (conf SunhpcConf) GrubBoot() bool {
return util.BoolP(conf.GrubBootP)
}
func (paths BuildConfig) NodesConf() string {
return path.Join(paths.Sysconfdir, "sunhpc", "nodes.conf")
}
func (paths BuildConfig) AuthenticationConf() string {
return path.Join(paths.Sysconfdir, "sunhpc", "auth.conf")
}
func (paths BuildConfig) OciBlobCachedir() string {
return path.Join(paths.Cachedir, "sunhpc")
}
func (paths BuildConfig) SiteOverlaydir() string {
return paths.WWOverlaydir
}
func (paths BuildConfig) DistributionOverlaydir() string {
return path.Join(paths.Datadir, "sunhpc", "overlays")
}
func (paths BuildConfig) OverlayProvisiondir() string {
return path.Join(paths.WWProvisiondir, "overlays")
}

View File

@@ -0,0 +1,5 @@
package config
type SPClientConf struct {
Port uint16 `yaml:"port,omitempty" default:"0"`
}

View File

@@ -0,0 +1,21 @@
package config
import (
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util"
)
// DHCPConf represents the configuration for the DHCP service that
// Sunhpc will configure.
type DHCPConf struct {
EnabledP *bool `yaml:"enabled,omitempty" default:"true"`
Template string `yaml:"template,omitempty" default:"default"`
RangeStart string `yaml:"range start,omitempty"`
RangeEnd string `yaml:"range end,omitempty"`
Range6Start string `yaml:"range6 start,omitempty"`
Range6End string `yaml:"range6 end,omitempty"`
SystemdName string `yaml:"systemd name,omitempty" default:"dhcpd"`
}
func (conf DHCPConf) Enabled() bool {
return util.BoolP(conf.EnabledP)
}

View File

@@ -0,0 +1,21 @@
package config
import (
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util"
)
// DNSmasqConf represents the configuration for the DNSmasq service that
// Sunhpc will configure.
type DNSMASQConf struct {
EnabledP *bool `yaml:"enabled,omitempty" default:"true"`
Template string `yaml:"template,omitempty" default:"default"`
RangeStart string `yaml:"range start,omitempty"`
RangeEnd string `yaml:"range end,omitempty"`
Range6Start string `yaml:"range6 start,omitempty"`
Range6End string `yaml:"range6 end,omitempty"`
SystemdName string `yaml:"systemd name,omitempty" default:"dhcpd"`
}
func (conf DNSMASQConf) Enabled() bool {
return util.BoolP(conf.EnabledP)
}

View File

@@ -0,0 +1,23 @@
package config
import (
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util"
)
// A MountEntry represents a bind mount that is applied to an image
// during exec and shell.
type MountEntry struct {
Source string `yaml:"source"`
Dest string `yaml:"dest,omitempty"`
ReadOnlyP *bool `yaml:"readonly,omitempty"`
Options string `yaml:"options,omitempty"` // ignored at the moment
CopyP *bool `yaml:"copy,omitempty"` // temporarily copy the file into the image
}
func (mount MountEntry) ReadOnly() bool {
return util.BoolP(mount.ReadOnlyP)
}
func (mount MountEntry) Copy() bool {
return util.BoolP(mount.CopyP)
}

View File

@@ -0,0 +1,34 @@
package config
import (
"github.com/creasty/defaults"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util"
)
// NFSConf represents the NFS configuration that will be used by
// Sunhpc to generate exports on the server and mounts on compute
// nodes.
type NFSConf struct {
EnabledP *bool `yaml:"enabled,omitempty" default:"true"`
ExportsExtended []*NFSExportConf `yaml:"export paths,omitempty" default:"[]"`
SystemdName string `yaml:"systemd name,omitempty" default:"nfsd"`
}
func (conf NFSConf) Enabled() bool {
return util.BoolP(conf.EnabledP)
}
// An NFSExportConf reprents a single NFS export / mount.
type NFSExportConf struct {
Path string `yaml:"path" default:"/dev/null"`
ExportOptions string `yaml:"export options,omitempty" default:"rw,sync,no_subtree_check"`
}
// Implements the Unmarshal interface for NFSConf to set default
// values.
func (conf *NFSConf) Unmarshal(unmarshal func(interface{}) error) error {
if err := defaults.Set(conf); err != nil {
return err
}
return nil
}

262
internal/pkg/config/root.go Normal file
View File

@@ -0,0 +1,262 @@
// Package config reads, parses, and represents the sunhpc.conf
// config file.
//
// sunhpc.conf is a yaml-formatted configuration file that includes
// configuration for the sunhpc daemon and commands, as well as the
// DHCP, TFTP and NFS services that sunhpc manages.
package config
import (
"bytes"
"fmt"
"net"
"os"
"reflect"
"strconv"
"github.com/creasty/defaults"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog"
"gopkg.in/yaml.v3"
)
var cachedConf SunhpcYaml
// SunhpcYaml is the main Sunhpc configuration structure. It stores
// some information about the Sunhpc server locally, and has
// [SunhpcConf], [DHCPConf], [TFTPConf], and [NFSConf] sub-sections.
type SunhpcYaml struct {
Comment string `yaml:"comment,omitempty"`
Ipaddr string `yaml:"ipaddr,omitempty"`
Netmask string `yaml:"netmask,omitempty"`
Network string `yaml:"network,omitempty"`
Fqdn string `yaml:"fqdn,omitempty"`
Ipaddr6 string `yaml:"ipaddr6,omitempty"`
PrefixLen6 string `yaml:"prefixlen6,omitempty"`
Sunhpc *SunhpcConf `yaml:"sunhpc,omitempty"`
API *APIConf `yaml:"api,omitempty"`
DNSMASQ *DNSMASQConf `yaml:"dhcp,omitempty"`
DHCP *DHCPConf `yaml:"dhcp,omitempty"`
TFTP *TFTPConf `yaml:"tftp,omitempty"`
NFS *NFSConf `yaml:"nfs,omitempty"`
SSH *SSHConf `yaml:"ssh,omitempty"`
MountsImage []*MountEntry `yaml:"image mounts,omitempty" default:"[{\"source\": \"/etc/resolv.conf\", \"dest\": \"/etc/resolv.conf\"}]"`
Paths *BuildConfig `yaml:"paths,omitempty"`
//Client *SPClientConf `yaml:"client,omitempty"`
sunhpcconf string
autodetected bool
}
// New caches and returns a new [SunhpcYaml] initialized with empty
// values, clearing replacing any previously cached value.
func New() *SunhpcYaml {
cachedConf = SunhpcYaml{}
cachedConf.sunhpcconf = ""
cachedConf.Sunhpc = new(SunhpcConf)
cachedConf.DNSMASQ = new(DNSMASQConf)
cachedConf.Paths = new(BuildConfig)
cachedConf.DHCP = new(DHCPConf)
cachedConf.TFTP = new(TFTPConf)
cachedConf.NFS = new(NFSConf)
cachedConf.SSH = new(SSHConf)
cachedConf.API = new(APIConf)
if err := defaults.Set(&cachedConf); err != nil {
panic(err)
}
return &cachedConf
}
// Get returns a previously cached [SunhpcYaml] if it exists, or returns
// a new SunhpcYaml.
func Get() *SunhpcYaml {
// NOTE: This function can be called before any log level is set
// so using splog.Verbose or splog.Debug won't work
if reflect.ValueOf(cachedConf).IsZero() {
cachedConf = *New()
}
return &cachedConf
}
// Read populates [SunhpcYaml] with the values from a configuration
// file.
func (conf *SunhpcYaml) Read(confFileName string, autodetect bool) error {
splog.Debug("Reading sunhpc.conf from: %s", confFileName)
conf.sunhpcconf = confFileName
if data, err := os.ReadFile(confFileName); err != nil {
return err
} else if err := conf.Parse(data, autodetect); err != nil {
return err
} else {
return nil
}
}
// Parse populates [SunhpcYaml] with the values from a yaml document.
func (conf *SunhpcYaml) Parse(data []byte, autodetect bool) error {
// ipxe binaries are merged not overwritten, store defaults separate
defIpxe := make(map[string]string)
for k, v := range conf.TFTP.IpxeBinaries {
defIpxe[k] = v
delete(conf.TFTP.IpxeBinaries, k)
}
if err := yaml.Unmarshal(data, &conf); err != nil {
return err
}
if len(conf.TFTP.IpxeBinaries) == 0 {
conf.TFTP.IpxeBinaries = defIpxe
}
if ip, network, err := net.ParseCIDR(conf.Ipaddr); err == nil {
conf.Ipaddr = ip.String()
if conf.Network == "" {
conf.Network = network.IP.String()
}
if conf.Netmask == "" {
conf.Netmask = net.IP(network.Mask).String()
}
}
if autodetect {
if conf.Ipaddr == "" {
if ip := GetOutboundIP(); ip != nil {
conf.Ipaddr = ip.String()
conf.autodetected = true
}
}
if conf.Netmask == "" {
if ip := net.ParseIP(conf.Ipaddr); ip != nil {
if network, err := GetIPNetForIP(ip); err == nil {
conf.Netmask = net.IP(network.Mask).String()
conf.autodetected = true
}
}
}
if conf.Network == "" {
if ip := net.ParseIP(conf.Ipaddr); ip != nil {
if mask := net.IPMask(net.ParseIP(conf.Netmask)); mask != nil {
conf.Network = ip.Mask(mask).String()
conf.autodetected = true
}
}
}
}
if ip := net.ParseIP(conf.Ipaddr6); ip != nil {
if prefix, err := strconv.Atoi(conf.PrefixLen6); err == nil {
conf.Ipaddr6 = ip.String()
conf.PrefixLen6 = strconv.Itoa(prefix)
}
}
if ip, net, err := net.ParseCIDR(conf.Ipaddr6); err == nil {
conf.Ipaddr6 = ip.String()
prefixLen6, _ := net.Mask.Size()
conf.PrefixLen6 = strconv.Itoa(prefixLen6)
}
return nil
}
func (config *SunhpcYaml) Network6() string {
cidr := fmt.Sprintf("%s/%s", config.Ipaddr6, config.PrefixLen6)
_, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return ""
}
return ipnet.IP.String()
}
func (config *SunhpcYaml) NetworkCIDR() string {
if config.Network == "" || config.Netmask == "" {
return ""
}
cidr := net.IPNet{
IP: net.ParseIP(config.Network),
Mask: net.IPMask(net.ParseIP(config.Netmask)),
}
if cidr.IP == nil || cidr.Mask == nil {
return ""
}
return cidr.String()
}
func (config *SunhpcYaml) NetworkCIDR6() string {
cidr := fmt.Sprintf("%s/%s", config.Ipaddr6, config.PrefixLen6)
_, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return ""
}
return ipnet.String()
}
func (config *SunhpcYaml) IpCIDR() string {
if config.Ipaddr == "" || config.Netmask == "" {
return ""
}
cidr := net.IPNet{
IP: net.ParseIP(config.Ipaddr),
Mask: net.IPMask(net.ParseIP(config.Netmask)),
}
if cidr.IP == nil || cidr.Mask == nil {
return ""
}
return cidr.String()
}
func (config *SunhpcYaml) IpCIDR6() string {
cidr := fmt.Sprintf("%s/%s", config.Ipaddr6, config.PrefixLen6)
ip, _, err := net.ParseCIDR(cidr)
if err != nil || ip == nil || ip.To4() != nil {
return ""
}
return cidr
}
// InitializedFromFile returns true if [SunhpcYaml] memory was read from
// a file, or false otherwise.
func (conf *SunhpcYaml) InitializedFromFile() bool {
return conf.sunhpcconf != ""
}
func (conf *SunhpcYaml) GetSunhpcConf() string {
return conf.sunhpcconf
}
func (conf *SunhpcYaml) Autodetected() bool {
return conf.autodetected
}
func (config *SunhpcYaml) Dump() ([]byte, error) {
var buf bytes.Buffer
yamlEncoder := yaml.NewEncoder(&buf)
yamlEncoder.SetIndent(2)
err := yamlEncoder.Encode(config)
return buf.Bytes(), err
}
func (config *SunhpcYaml) PersistToFile(configFile string) (err error) {
out, dumpErr := config.Dump()
if dumpErr != nil {
splog.Error("%s", dumpErr)
return dumpErr
}
file, err := os.OpenFile(configFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
splog.Error("%s", err)
return err
}
defer func() {
if cerr := file.Close(); cerr != nil && err == nil {
err = cerr
}
}()
_, err = file.WriteString(string(out))
if err != nil {
return err
}
splog.Debug("persisted: %s", configFile)
return nil
}

View File

@@ -0,0 +1,5 @@
package config
type SSHConf struct {
KeyTypes []string `yaml:"key types,omitempty" default:"[\"ed25519\",\"ecdsa\",\"rsa\",\"dsa\"]"`
}

View File

@@ -0,0 +1,58 @@
package config
import (
"fmt"
"net"
)
func GetOutboundIP() net.IP {
conn, err := net.Dial("udp", "192.0.2.1:80")
if err != nil {
return nil
}
defer func() { _ = conn.Close() }()
localAddr := conn.LocalAddr().(*net.UDPAddr)
return localAddr.IP
}
func GetIPNetForIP(ip net.IP) (*net.IPNet, error) {
interfaces, err := net.Interfaces()
if err != nil {
return nil, fmt.Errorf("failed to get network interfaces: %v", err)
}
for _, iface := range interfaces {
// skip interfaces that are down or not relevant
if iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue // try next interface
}
for _, addr := range addrs {
// We expect addr to be of type *net.IPNet.
var ipNet *net.IPNet
switch v := addr.(type) {
case *net.IPNet:
ipNet = v
case *net.IPAddr:
// Wrap net.IPAddr in an IPNet with its default mask.
ipNet = &net.IPNet{IP: v.IP, Mask: v.IP.DefaultMask()}
}
if ipNet == nil {
continue
}
// Check if the IP matches (for IPv4, Equal works well)
if ipNet.IP.Equal(ip) {
return ipNet, nil
}
}
}
return nil, fmt.Errorf("could not find IPNet for IP %v", ip)
}

View File

@@ -0,0 +1,22 @@
package configure
import (
"fmt"
sunhpccconf "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/config"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog"
//"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util"
)
func DHCP() (err error) {
controller := sunhpccconf.Get()
if !controller.DHCP.Enabled() {
splog.Warn("This system is not configured as a Sunhpc DHCP controller.")
return
}
fmt.Printf("Enabling and restarting the DHCP services\n")
return
}

View File

@@ -0,0 +1,22 @@
package configure
import (
sunhpcconf "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/config"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog"
)
func SUNHPCD() (err error) {
controller := sunhpcconf.Get()
if controller.Sunhpc.SystemdName != "" {
splog.Info("Enabling and restarting the Sunhpc server")
err = util.SystemdStart(controller.Sunhpc.SystemdName)
if err != nil {
return err
}
} else {
splog.Warn("Not (re)starting Sunhpc server: no systemd name configured")
}
return nil
}

View File

@@ -0,0 +1,118 @@
package configure
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path"
"time"
sunhpcconf "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/config"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog"
)
// TLS ensures TLS keys exist if TLS is enabled.
// If force is true, regenerates even if keys already exist.
// Returns true if new keys were generated.
func TLS(force bool) (bool, error) {
conf := sunhpcconf.Get()
if !conf.Sunhpc.TLSEnabled() {
return false, nil
}
keystore := path.Join(conf.Paths.Sysconfdir, "sunhpc", "tls")
keyFile := path.Join(keystore, "sunhpc.key")
certFile := path.Join(keystore, "sunhpc.crt")
if !force && util.IsFile(keyFile) && util.IsFile(certFile) {
splog.Info("TLS keys already exist in %s", keystore)
return false, nil
}
if err := GenTLSKeys(); err != nil {
return false, err
}
splog.Info("TLS keys generated in %s", keystore)
return true, nil
}
// GenTLSKeys generates new TLS keys and certificate unconditionally.
func GenTLSKeys() error {
conf := sunhpcconf.Get()
keystore := path.Join(conf.Paths.Sysconfdir, "sunhpc", "tls")
if err := os.MkdirAll(keystore, 0755); err != nil {
return fmt.Errorf("could not create keystore directory: %w", err)
}
keyFile := path.Join(keystore, "sunhpc.key")
certFile := path.Join(keystore, "sunhpc.crt")
splog.Verbose("Generating new x509 keys in %s", keystore)
priv, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return fmt.Errorf("failed to generate rsa key: %w", err)
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return fmt.Errorf("failed to generate serial number: %w", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: "Sunhpc Server",
Organization: []string{"Sunhpc"},
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour * 24 * 365 * 10),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
if ip := net.ParseIP(conf.Ipaddr); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
}
if ip := net.ParseIP(conf.Ipaddr6); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
}
if conf.Fqdn != "" {
template.DNSNames = append(template.DNSNames, conf.Fqdn)
}
template.DNSNames = append(template.DNSNames, "sunhpc")
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return fmt.Errorf("failed to create certificate: %w", err)
}
keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to open key file for writing: %w", err)
}
defer func() { _ = keyOut.Close() }()
if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil {
return fmt.Errorf("failed to write data to key file: %w", err)
}
certOut, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("failed to open cert file for writing: %w", err)
}
defer func() { _ = certOut.Close() }()
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
return fmt.Errorf("failed to write data to cert file: %w", err)
}
return nil
}

View File

@@ -1,4 +1,4 @@
package wwlog
package splog
import (
"fmt"

464
internal/pkg/util/util.go Normal file
View File

@@ -0,0 +1,464 @@
package util
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"net"
"os"
"os/exec"
//"path"
"path/filepath"
"regexp"
"strings"
"syscall"
//"time"
"gopkg.in/yaml.v3"
"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog"
)
func IsDir(path string) bool {
splog.Debug("Checking if path exists as a directory: %s", path)
if path == "" {
return false
}
if stat, err := os.Stat(path); err == nil && stat.IsDir() {
return true
}
return false
}
func IsFile(path string) bool {
splog.Debug("Checking if path exists as a file: %s", path)
if path == "" {
return false
}
if stat, err := os.Lstat(path); err == nil && !stat.IsDir() {
return true
}
return false
}
func ReadFile(path string) ([]string, error) {
lines := []string{}
f, err := os.Open(path)
if err != nil {
return nil, err
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
_ = f.Close()
return lines, nil
}
func ValidString(pattern string, s string) bool {
if b, _ := regexp.MatchString(pattern, s); b {
return true
}
return false
}
func FindFiles(path string) []string {
var ret []string
splog.Debug("Finding files in path: %s", path)
err := filepath.Walk(path, func(location string, info os.FileInfo, err error) error {
if err != nil {
splog.Warn("Error walking path %s: %v", location, err)
return err
}
// Get the relative path from the base directory
relPath, relErr := filepath.Rel(path, location)
if relErr != nil {
splog.Warn("Error computing relative path for %s: %v", location, relErr)
return relErr
}
if relPath == "." {
return nil
}
if info.IsDir() {
splog.Debug("FindFiles() found directory: %s", relPath)
ret = append(ret, relPath+"/")
} else {
splog.Debug("FindFiles() found file: %s", relPath)
ret = append(ret, relPath)
}
return nil
})
if err != nil {
splog.Warn("Error during file walk: %v", err)
return ret
}
return ret
}
/*
Finds all files under a given directory with tar like include and ignore patterns.
/foo/*
will match /foo/baar/ and /foo/baar/sibling
*/
func FindFilterFiles(
path string,
includePattern []string,
ignorePattern []string,
ignore_xdev bool,
) (ofiles []string, err error) {
splog.Debug("Finding files: %s include: %s ignore: %s", path, includePattern, ignorePattern)
// Preprocess patterns to remove leading (and trailing) /, as we are handling relative paths
for i, pattern := range ignorePattern {
ignorePattern[i] = strings.Trim(pattern, "/")
}
// Convert the base path to an absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return ofiles, fmt.Errorf("failed to resolve absolute path: %s: %w", path, err)
}
// Expand the include list
var globedInclude []string
for _, include := range includePattern {
globed, err := filepath.Glob(filepath.Join(absPath, include))
if err != nil {
return ofiles, fmt.Errorf("failed to glob pattern %s: %w", include, err)
}
globedInclude = append(globedInclude, globed...)
}
var dev uint64
if ignore_xdev {
splog.Debug("Ignoring cross-device (xdev) files")
pathStat, err := os.Stat(absPath)
if err != nil {
return ofiles, fmt.Errorf("failed to stat base path: %s: %w", absPath, err)
}
dev = pathStat.Sys().(*syscall.Stat_t).Dev
}
for _, inc := range globedInclude {
splog.Debug("Processing include pattern: %s", inc)
stat, err := os.Lstat(inc)
if err != nil {
return ofiles, fmt.Errorf("failed to stat include: %s: %w", inc, err)
}
if stat.IsDir() {
// Walk the directory
err = filepath.WalkDir(inc, func(location string, info fs.DirEntry, err error) error {
if err != nil {
return err
}
relPath, relErr := filepath.Rel(absPath, location)
if relErr != nil {
splog.Warn("Error computing relative path for %s: %v", location, relErr)
return relErr
}
if relPath == "." {
return nil
}
fsInfo, err := info.Info()
if err != nil {
return err
}
if ignore_xdev && fsInfo.Sys().(*syscall.Stat_t).Dev != dev {
splog.Debug("Ignored (cross-device): %s", location)
return nil
}
for _, ignoredPattern := range ignorePattern {
if ignored, _ := filepath.Match(ignoredPattern, relPath); ignored {
splog.Debug("Ignored %s due to pattern %s", relPath, ignoredPattern)
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
}
ofiles = append(ofiles, relPath)
return nil
})
if err != nil {
return ofiles, fmt.Errorf("error walking directory %s: %w", inc, err)
}
} else {
// Add the file directly
relPath, relErr := filepath.Rel(absPath, inc)
if relErr != nil {
splog.Warn("Error computing relative path for %s: %v", inc, relErr)
return ofiles, relErr
}
ofiles = append(ofiles, relPath)
}
}
return ofiles, nil
}
// ******************************************************************************
func ExecInteractive(command string, a ...string) error {
splog.Debug("ExecInteractive(%s, %s)", command, a)
c := exec.Command(command, a...)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
err := c.Run()
return err
}
func SystemdStart(systemdName string) error {
startCmd := fmt.Sprintf("systemctl restart %s", systemdName)
enableCmd := fmt.Sprintf("systemctl enable %s", systemdName)
splog.Debug("Setting up Systemd service: %s", systemdName)
err := ExecInteractive("/bin/sh", "-c", startCmd)
if err != nil {
return fmt.Errorf("failed to run start cmd: %w", err)
}
err = ExecInteractive("/bin/sh", "-c", enableCmd)
if err != nil {
return fmt.Errorf("failed to run enable cmd: %w", err)
}
return nil
}
func CopyUIDGID(source string, dest string) error {
info, err := os.Stat(source)
if err != nil {
return err
}
// root is always good, if we failt to get UID/GID of a file
var UID = 0
var GID = 0
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
UID = int(stat.Uid)
GID = int(stat.Gid)
}
splog.Debug("Chown %d:%d '%s'", UID, GID, dest)
err = os.Chown(dest, UID, GID)
return err
}
func IncrementIPv4(start net.IP, inc uint) net.IP {
ipv4 := start.To4()
v4_int := uint(ipv4[0])<<24 + uint(ipv4[1])<<16 + uint(ipv4[2])<<8 + uint(ipv4[3])
v4_int += inc
v4_o3 := byte(v4_int & 0xFF)
v4_o2 := byte((v4_int >> 8) & 0xFF)
v4_o1 := byte((v4_int >> 16) & 0xFF)
v4_o0 := byte((v4_int >> 24) & 0xFF)
ipv4_new := net.IPv4(v4_o0, v4_o1, v4_o2, v4_o3)
return ipv4_new
}
/*
Appending the lines to the given file
*/
func AppendLines(fileName string, lines []string) (err error) {
splog.Verbose("appending %v lines to %s", len(lines), fileName)
file, err := os.OpenFile(fileName, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("can't open file: %s: %w", fileName, err)
}
defer func() {
if cerr := file.Close(); cerr != nil && err == nil {
err = cerr
}
}()
for _, line := range lines {
splog.Debug("Appending '%s' to %s", line, fileName)
if _, err := fmt.Fprintf(file, "%s\n", line); err != nil {
return fmt.Errorf("can't write to file: %s: %w", fileName, err)
}
}
return nil
}
/*
******************************************************************************
Compress a file using gzip or pigz
*/
func FileGz(
file string,
) (err error) {
file_gz := file + ".gz"
if IsFile(file_gz) {
err := os.Remove(file_gz)
if err != nil {
return fmt.Errorf("could not remove existing file: %s: %w", file_gz, err)
}
}
compressor, err := exec.LookPath("pigz")
if err != nil {
splog.Verbose("Could not locate PIGZ")
compressor, err = exec.LookPath("gzip")
if err != nil {
splog.Verbose("Could not locate GZIP")
return fmt.Errorf("no compressor program for image file: %s: %w", file_gz, err)
}
}
splog.Verbose("Using compressor program: %s", compressor)
proc := exec.Command(
compressor,
"--keep",
file)
out, err := proc.CombinedOutput()
if len(out) > 0 {
outStr := string(out[:])
if err != nil && strings.HasSuffix(compressor, "gzip") && strings.Contains(outStr, "unrecognized option") {
var gzippedFile *os.File
var gzipStderr io.ReadCloser
/* Older version of gzip, try it another way: */
splog.Verbose("%s does not recognize the --keep flag, trying redirected stdout", compressor)
/* Open the output file for writing: */
gzippedFile, err = os.Create(file_gz)
if err != nil {
return fmt.Errorf("unable to open compressed image file for writing: %s: %w", file_gz, err)
}
/* We'll execute gzip with output to stdout and attach stdout to the compressed file we just
created:
*/
proc = exec.Command(
compressor,
"--stdout",
file)
proc.Stdout = gzippedFile
gzipStderr, err = proc.StderrPipe()
if err != nil {
return fmt.Errorf("unable to open stderr pipe for compression program: %s: %w", compressor, err)
}
/* Execute the command: */
err = proc.Start()
if err != nil {
_ = proc.Wait()
_ = gzippedFile.Close()
_ = os.Remove(file_gz)
err = fmt.Errorf("unable to successfully execute compression program: %s: %w", compressor, err)
} else {
err = proc.Wait()
if cerr := gzippedFile.Close(); cerr != nil {
splog.Warn("failed to close compressed image file %s: %s", file_gz, cerr)
}
if err != nil {
_ = os.Remove(file_gz)
err = fmt.Errorf("unable to successfully create compressed image file: %s: %w", file_gz, err)
} else {
splog.Verbose("Successfully compressed image file: %s", file_gz)
}
}
out, _ = io.ReadAll(gzipStderr)
}
splog.Debug(string(out))
}
return err
}
/*
Get size of given directory in bytes
*/
func DirSize(path string) (int64, error) {
var size int64
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
size += info.Size()
}
return err
})
return size, err
}
/*
Convert bytes to human friendly format
*/
func ByteToString(b int64) string {
const base = 1024
if b < base {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(base), 0
for n := b / base; n >= base; n /= base {
div *= base
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
func HashFile(file *os.File) (string, error) {
if prevOffset, err := file.Seek(0, 0); err != nil {
return "", err
} else {
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return "", err
}
if _, err := file.Seek(prevOffset, 0); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}
}
func EncodeYaml(data interface{}) ([]byte, error) {
buf := new(bytes.Buffer)
encoder := yaml.NewEncoder(buf)
encoder.SetIndent(2)
err := encoder.Encode(data)
return buf.Bytes(), err
}
func EqualYaml(a interface{}, b interface{}) (bool, error) {
aYaml, err := EncodeYaml(a)
if err != nil {
return false, err
}
bYaml, err := EncodeYaml(b)
if err != nil {
return false, err
}
return bytes.Equal(aYaml, bYaml), nil
}
func BoolP(p *bool) bool {
return p != nil && *p
}

BIN
sunhpc

Binary file not shown.