diff --git a/README.md b/README.md index 420b77e..35b0d2d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ - [qBittorrent](https://github.com/qbittorrent/qBittorrent) - [Syncthing](https://github.com/syncthing/syncthing) - [National Weather Service](https://www.weather.gov/) + - [Amcrest Cameras](https://amcrest.com/) + - [Infisical](https://infisical.com/) ## NATS Topics diff --git a/internal/api/api.go b/internal/api/api.go index b2a39da..67c3a28 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -34,15 +34,15 @@ const ( HA_STATE_PREFIX = "homeassistant.states" ) -func Listen(parentLogger *slog.Logger, readme []byte) { +func Listen(parentLogger *slog.Logger, parentConfig *config.HatsConfig, readme []byte) { logger = parentLogger + cfg = parentConfig // render readme to HTML p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock) doc := p.Parse(readme) homepage = string(markdown.Render(doc, html.NewRenderer(html.RendererOptions{Flags: html.CommonFlags | html.HrefTargetBlank}))) - cfg = config.FromEnvironment() haClient = homeassistant.NewRestClient(cfg.GetHomeAssistantBaseUrl(), cfg.HomeAssistantToken) router := chi.NewRouter() diff --git a/internal/homeassistant/subscriber.go b/internal/homeassistant/subscriber.go index e7e6f34..4867fa0 100644 --- a/internal/homeassistant/subscriber.go +++ b/internal/homeassistant/subscriber.go @@ -41,9 +41,9 @@ func CloseSubscription() error { return nil } -func Subscribe(parentLogger *slog.Logger) error { +func Subscribe(parentLogger *slog.Logger, parentConfig *config.HatsConfig) error { logger = parentLogger - cfg = config.FromEnvironment() + cfg = parentConfig var err error url := cfg.GetHomeAssistantWebsocketUrl() @@ -72,7 +72,7 @@ func reconnect() { time.Sleep(time.Duration(attempts) * 5 * time.Second) logger.Info("Trying to reconnect to Home Assistant", "attempt", attempts) - err := Subscribe(logger) + err := Subscribe(logger, cfg) if err == nil { break } diff --git a/internal/nats/client.go b/internal/nats/client.go index 9a0119a..3d53f06 100644 --- a/internal/nats/client.go +++ b/internal/nats/client.go @@ -25,10 +25,10 @@ func Close() { client.Close() } -func JetstreamConnect(parentContext context.Context, parentLogger *slog.Logger) error { +func JetstreamConnect(parentContext context.Context, parentLogger *slog.Logger, parentConfig *config.HatsConfig) error { ctx = parentContext logger = parentLogger - cfg = config.FromEnvironment() + cfg = parentConfig var err error client = n.DefaultNatsConnection().WithHostName(cfg.NatsHost).WithPort(cfg.NatsPort).WithConnectionOption(nats.Name(cfg.NatsClientName)) diff --git a/internal/nats/timers.go b/internal/nats/timers.go index 5c544e9..c1e483f 100644 --- a/internal/nats/timers.go +++ b/internal/nats/timers.go @@ -5,7 +5,6 @@ import ( "strings" "time" - "code.jhot.me/jhot/hats/pkg/config" "code.jhot.me/jhot/hats/pkg/homeassistant" ) @@ -15,7 +14,6 @@ var ( func initHomeAssistantClient() { if haClient == nil { - cfg = config.FromEnvironment() haClient = homeassistant.NewRestClient(cfg.GetHomeAssistantBaseUrl(), cfg.HomeAssistantToken) } } diff --git a/main.go b/main.go index dc5629b..60ed118 100644 --- a/main.go +++ b/main.go @@ -26,15 +26,21 @@ var ( ) func main() { - cfg = config.FromEnvironment() + var err error + cfg, err = config.New() ctx, cancel = context.WithCancel(context.Background()) logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: cfg.GetLogLevel(), })) + + if err != nil { + logger.Error("Error during config initialization", "error", err) + } + interrupt = make(chan os.Signal, 1) signal.Notify(interrupt, os.Interrupt) - err := nats.JetstreamConnect(ctx, logger) + err = nats.JetstreamConnect(ctx, logger, cfg) if err != nil { panic(err) } @@ -52,7 +58,7 @@ func main() { nats.GetExistingSchedules() defer nats.StopSchedules() - err = homeassistant.Subscribe(logger) + err = homeassistant.Subscribe(logger, cfg) if err != nil { panic(err) } @@ -60,7 +66,7 @@ func main() { ntfy.InitClient(cfg) - api.Listen(logger, readme) + api.Listen(logger, cfg, readme) defer api.Close() for sig := range interrupt { diff --git a/pkg/config/config.go b/pkg/config/config.go index 2aadeb7..8d3927d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -3,71 +3,168 @@ package config import ( "fmt" "log/slog" + "os" + "reflect" "strconv" "strings" - "code.jhot.me/jhot/hats/internal/util" + "code.jhot.me/jhot/hats/pkg/infisical" ) type HatsConfig struct { - LogLevl string + LogLevl string `config:"LOG_LEVEL" default:"INFO"` - HomeAssistantHost string - HomeAssistantPort string - HomeAssistantSecure bool - HomeAssistantToken string + InfisicalHost string `config:"INFISICAL_HOST" default:"http://infisical:8080"` + InfisicalClientID string `config:"INFISICAL_CLIENT" default:""` + InfisicalClientSecret string `config:"INFISICAL_SECRET" default:""` + InfisicalProjectID string `config:"INFISICAL_PROJECT" default:""` + InfisicalEnvironment string `config:"INFISICAL_ENVIRONMENT" default:"prod"` - NatsHost string - NatsPort string - NatsToken string - NatsClientName string + HomeAssistantHost string `config:"HASS_HOST" default:"127.0.0.1"` + HomeAssistantPort string `config:"HASS_PORT" default:"8123"` + HomeAssistantSecure bool `config:"HASS_SECURE" default:"false"` + HomeAssistantToken string `config:"HASS_TOKEN" default:""` - HatsHost string - HatsPort string - HatsToken string - HatsSecure bool + NatsHost string `config:"NATS_HOST" default:"127.0.0.1"` + NatsPort string `config:"NATS_PORT" default:"4222"` + NatsToken string `config:"NATS_TOKEN" default:""` + NatsClientName string `config:"NATS_CLIENT_NAME" default:"hats"` - NtfyHost string - NtfyToken string + HatsHost string `config:"HATS_HOST" default:"hats"` + HatsPort string `config:"HATS_PORT" default:"8888"` + HatsToken string `config:"HATS_TOKEN" default:""` + HatsSecure bool `config:"HATS_SECURE" default:"false"` - ConfigDir string + NtfyHost string `config:"NTFY_HOST" default:"https://ntfy.sh"` + NtfyToken string `config:"NTFY_TOKEN" default:""` + + SyncthingHost string `config:"SYNCTHING_HOST" default:"http://127.0.0.1:8384"` + SyncthingToken string `config:"SYNCTHING_TOKEN" default:""` + + GokapiHost string `config:"GOKAPI_HOST" default:"http://gokapi:53842"` + GokapiToken string `config:"GOKAPI_TOKEN" default:""` + + QbittorrentHost string `config:"QBITTORRENT_HOST" default:"http://qbittorrent:8080"` + QbittorrentUser string `config:"QBITTORRENT_USER" default:""` + QbittorrentPassword string `config:"QBITTORRENT_PASS" default:""` + + ConfigDir string `config:"CONFIG_DIR" default:"/config"` + + infisicalClient *infisical.InfisicalClient + infisicalRetrievalOpts *infisical.RetrieveSecretOptions } -func FromEnvironment() *HatsConfig { - config := &HatsConfig{ - LogLevl: util.GetEnvWithDefault("LOG_LEVEL", "INFO"), +func New() (*HatsConfig, error) { + cfg := &HatsConfig{} + cfg.SetValues(ConfigValueSourceDefault).SetValues(ConfigValueSourceEnv) - HomeAssistantHost: util.GetEnvWithDefault("HASS_HOST", "127.0.0.1"), - HomeAssistantPort: util.GetEnvWithDefault("HASS_PORT", "8123"), - HomeAssistantToken: util.GetEnvWithDefault("HASS_TOKEN", ""), + if cfg.InfisicalConfigured() { + cfg.infisicalClient = infisical.New(cfg.InfisicalHost, cfg.InfisicalClientID, cfg.InfisicalClientSecret) - NatsHost: util.GetEnvWithDefault("NATS_HOST", "127.0.0.1"), - NatsPort: util.GetEnvWithDefault("NATS_PORT", "4222"), - NatsToken: util.GetEnvWithDefault("NATS_TOKEN", ""), - NatsClientName: util.GetEnvWithDefault("NATS_CLIENT_NAME", "hats"), + err := cfg.infisicalClient.Login() + if err != nil { + cfg.infisicalClient = nil + return cfg, fmt.Errorf("error logging in to Infisical: %w", err) + } - HatsHost: util.GetEnvWithDefault("HATS_HOST", "hats"), - HatsPort: util.GetEnvWithDefault("HATS_PORT", "8888"), - HatsToken: util.GetEnvWithDefault("HATS_TOKEN", ""), + cfg.infisicalRetrievalOpts = &infisical.RetrieveSecretOptions{ + WorkspaceID: cfg.InfisicalProjectID, + Environment: cfg.InfisicalEnvironment, + SecretPath: "/", + IncludeImports: false, + } - NtfyHost: util.GetEnvWithDefault("NTFY_HOST", "https://ntfy.sh"), - NtfyToken: util.GetEnvWithDefault("NTFY_TOKEN", ""), + secrets, err := cfg.infisicalClient.ListSecrets(cfg.infisicalRetrievalOpts) + if err != nil { + return cfg, fmt.Errorf("error getting Infisical secrets: %w", err) + } - ConfigDir: util.GetEnvWithDefault("CONFIG_DIR", "/config"), + secretsMap := make(map[string]string) + for _, secret := range secrets { + secretsMap[secret.SecretKey] = secret.SecretValue + } + + cfg.SetValues(ConfigValueSourceInfisical, secretsMap) } - config.HomeAssistantSecure, _ = strconv.ParseBool(util.GetEnvWithDefault("HASS_SECURE", "false")) - config.HatsSecure, _ = strconv.ParseBool(util.GetEnvWithDefault("HATS_SECURE", "false")) + return cfg, nil +} - return config +type ConfigValueSource string + +const ConfigValueSourceEnv ConfigValueSource = "env" +const ConfigValueSourceDefault ConfigValueSource = "default" +const ConfigValueSourceInfisical ConfigValueSource = "infisical" + +func (c *HatsConfig) SetValues(source ConfigValueSource, inputs ...map[string]string) *HatsConfig { + fields := reflect.VisibleFields(reflect.TypeOf(*c)) + + for _, field := range fields { + envName := field.Tag.Get("config") + if envName == "" { + continue + } + + var envValue string + switch source { + case ConfigValueSourceDefault: + envValue = field.Tag.Get("default") + case ConfigValueSourceEnv: + envValue = os.Getenv(envName) + case ConfigValueSourceInfisical: + if len(inputs) == 0 { + break + } + if val, found := inputs[0][envName]; found { + envValue = val + } + } + + if envValue == "" { + continue + } + + f := reflect.ValueOf(c).Elem().FieldByName(field.Name) + switch field.Type.Kind() { + case reflect.Bool: + f.SetBool(strings.EqualFold(envValue, "true")) + case reflect.Int: + parsed, err := strconv.ParseInt(envValue, 10, 64) + if err == nil { + f.SetInt(parsed) + } + case reflect.String: + f.SetString(envValue) + } + } + + return c +} + +func (c *HatsConfig) GetCustomSetting(name string, defaultValue string) string { + returnValue := defaultValue + + envValue := os.Getenv(name) + if envValue != "" { + returnValue = envValue + } + + if c.InfisicalConfigured() { + secret, _ := c.infisicalClient.GetSecret(name, c.infisicalRetrievalOpts) + if secret.SecretValue != "" { + returnValue = secret.SecretValue + } + } + + return returnValue } func (c *HatsConfig) GetHomeAssistantBaseUrl() string { - hassProtocol := "http" + protocol := "http" if c.HomeAssistantSecure { - hassProtocol += "s" + protocol += "s" } - return fmt.Sprintf("%s://%s:%s", hassProtocol, c.HomeAssistantHost, c.HomeAssistantPort) + return fmt.Sprintf("%s://%s:%s", protocol, c.HomeAssistantHost, c.HomeAssistantPort) } func (c *HatsConfig) GetHomeAssistantWebsocketUrl() string { @@ -102,3 +199,11 @@ func (c *HatsConfig) GetLogLevel() slog.Level { return slog.LevelInfo } } + +func (c *HatsConfig) InfisicalConfigured() bool { + return c.InfisicalHost != "" && + c.InfisicalClientID != "" && + c.InfisicalClientSecret != "" && + c.InfisicalProjectID != "" && + c.InfisicalEnvironment != "" +} diff --git a/pkg/gokapi/api.go b/pkg/gokapi/api.go index 848d461..b825d3b 100644 --- a/pkg/gokapi/api.go +++ b/pkg/gokapi/api.go @@ -27,10 +27,6 @@ func New(host string, token string) *GokapiClient { } } -func NewFromEnv() *GokapiClient { - return New(os.Getenv("GOKAPI_HOST"), os.Getenv("GOKAPI_TOKEN")) -} - type GokapiFile struct { ID string `json:"Id"` Name string `json:"Name"` diff --git a/pkg/infisical/api.go b/pkg/infisical/api.go new file mode 100644 index 0000000..a81d9f0 --- /dev/null +++ b/pkg/infisical/api.go @@ -0,0 +1,114 @@ +package infisical + +import ( + "fmt" + "time" + + "code.jhot.me/jhot/hats/internal/util" + "github.com/go-resty/resty/v2" +) + +type InfisicalClient struct { + restClient *resty.Client + host string + clientId string + clientSecret string + accessToken string + tokenExpiration time.Time +} + +func New(host string, clientId string, clientSecret string) *InfisicalClient { + return &InfisicalClient{ + restClient: resty.New().SetBaseURL(fmt.Sprintf("%s/api", host)), + host: host, + clientId: clientId, + clientSecret: clientSecret, + accessToken: "", + tokenExpiration: time.Now(), + } +} + +func (c *InfisicalClient) Login() error { + var result LoginResponse + _, err := util.CheckSuccess(c.restClient.R().SetFormData(map[string]string{ + "clientId": c.clientId, + "clientSecret": c.clientSecret, + }).SetResult(&result).Post("v1/auth/universal-auth/login")) + + if err != nil { + return err + } + + c.restClient.SetAuthScheme(result.TokenType).SetAuthToken(result.AccessToken) + c.accessToken = result.AccessToken + c.tokenExpiration = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) + return nil +} + +func (c *InfisicalClient) CheckToken() error { + if c.accessToken == "" { + return fmt.Errorf("access token empty, please login first") + } + + if time.Now().Before(c.tokenExpiration) { + return nil + } + + var result LoginResponse + _, err := util.CheckSuccess(c.restClient.R().SetResult(&result).SetBody(map[string]interface{}{ + "accessToken": c.accessToken, + }).Post("v1/auth/token/renew")) + + if err != nil { + return fmt.Errorf("error renewing token: %w", err) + } + + c.restClient.SetAuthScheme(result.TokenType).SetAuthToken(result.AccessToken) + c.accessToken = result.AccessToken + c.tokenExpiration = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) + + return nil +} + +func (c *InfisicalClient) ListSecrets(opts *RetrieveSecretOptions) ([]Secret, error) { + err := c.CheckToken() + if err != nil { + return []Secret{}, err + } + + var result SecretsResponse + _, err = util.CheckSuccess(c.restClient.R().SetResult(&result).SetQueryParams(map[string]string{ + "workspaceId": opts.WorkspaceID, + "environment": opts.Environment, + "secretPath": opts.SecretPath, + "include_imports": fmt.Sprintf("%t", opts.IncludeImports), + }).Get("v3/secrets/raw")) + + if err != nil { + return []Secret{}, err + } + + return result.Secrets, nil +} + +func (c *InfisicalClient) GetSecret(name string, opts *RetrieveSecretOptions) (Secret, error) { + err := c.CheckToken() + if err != nil { + return Secret{}, err + } + + var result SecretResponse + _, err = util.CheckSuccess(c.restClient.R().SetResult(&result).SetQueryParams(map[string]string{ + "workspaceId": opts.WorkspaceID, + "environment": opts.Environment, + "secretPath": opts.SecretPath, + "include_imports": fmt.Sprintf("%t", opts.IncludeImports), + "type": "shared", + }).Get("v3/secrets/raw/" + name)) + + if err != nil { + return Secret{}, err + } + + return result.Secret, nil +} diff --git a/pkg/infisical/structs.go b/pkg/infisical/structs.go new file mode 100644 index 0000000..2a105e7 --- /dev/null +++ b/pkg/infisical/structs.go @@ -0,0 +1,62 @@ +package infisical + +type LoginResponse struct { + AccessToken string `json:"accessToken"` + ExpiresIn int `json:"expiresIn"` + AccessTokenMaxTTL int `json:"accessTokenMaxTTL"` + TokenType string `json:"tokenType"` +} + +type RetrieveSecretOptions struct { + WorkspaceID string + Environment string + SecretPath string + IncludeImports bool +} + +func DefaultRetrieveSecretOptions() *RetrieveSecretOptions { + return &RetrieveSecretOptions{ + WorkspaceID: "", + Environment: "", + SecretPath: "/", + IncludeImports: false, + } +} + +func (o *RetrieveSecretOptions) WithWorkspaceID(workspaceId string) *RetrieveSecretOptions { + o.WorkspaceID = workspaceId + return o +} + +func (o *RetrieveSecretOptions) WithEnvironment(environment string) *RetrieveSecretOptions { + o.Environment = environment + return o +} + +func (o *RetrieveSecretOptions) WithSecretPath(secretPath string) *RetrieveSecretOptions { + o.SecretPath = secretPath + return o +} + +func (o *RetrieveSecretOptions) WithImports(includeImports bool) *RetrieveSecretOptions { + o.IncludeImports = includeImports + return o +} + +type Secret struct { + ID string `json:"_id"` + Environment string `json:"environment,omitempty"` + SecretComment string `json:"secretComment,omitempty"` + SecretKey string `json:"secretKey,omitempty"` + SecretValue string `json:"secretValue,omitempty"` + Version int `json:"version,omitempty"` + Workspace string `json:"workspace,omitempty"` +} + +type SecretsResponse struct { + Secrets []Secret `json:"secrets"` +} + +type SecretResponse struct { + Secret Secret `json:"secret"` +} diff --git a/pkg/qbittorrent/api.go b/pkg/qbittorrent/api.go index 40a55b9..4dc21f5 100644 --- a/pkg/qbittorrent/api.go +++ b/pkg/qbittorrent/api.go @@ -3,7 +3,6 @@ package qbittorrent import ( "fmt" "net/http" - "os" "strings" "time" @@ -21,12 +20,6 @@ func New(host string) *QbittorrentClient { } } -func NewFromEnv() (*QbittorrentClient, error) { - c := New(os.Getenv("QBITTORRENT_HOST")) - err := c.Login(os.Getenv("QBITTORRENT_USER"), os.Getenv("QBITTORRENT_PASS")) - return c, err -} - func (c *QbittorrentClient) Login(user string, pass string) error { resp, err := util.CheckSuccess(c.restClient.R().SetFormData(map[string]string{ "username": user, diff --git a/pkg/syncthing/rest.go b/pkg/syncthing/rest.go index 81abba4..1b18516 100644 --- a/pkg/syncthing/rest.go +++ b/pkg/syncthing/rest.go @@ -2,7 +2,6 @@ package syncthing import ( "fmt" - "os" "code.jhot.me/jhot/hats/internal/util" "github.com/go-resty/resty/v2" @@ -20,10 +19,6 @@ func New(host string, token string) *SyncthingClient { } } -func NewFromEnv() *SyncthingClient { - return New(os.Getenv("SYNCTHING_HOST"), os.Getenv("SYNCTHING_TOKEN")) -} - func (c *SyncthingClient) GetVersion() (VersionInfo, error) { var data VersionInfo _, err := util.CheckSuccess(c.restClient.R().SetResult(&data).Get("system/version"))