Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,22 @@ finchctl service deploy root@10.19.80.100
```

This deploys the stack and exposes services at `https://10.19.80.100`.
Admin credentials are in `~/.finch/config.json`.
Visualization and dashboards are available under `/grafana`.
TLS uses Traefik's default self-signed certificate.
Visualization and dashboards are available under `/grafana`. TLS uses Traefik's
default self-signed certificate. Credentials for Grafana are user `admin` and
password `admin`. mTLS secures communication between the Finch client and
service. The client certificate and key are generated during deployment and
stored in `~/.config/finch.json`.

For a public machine with DNS and Let's Encrypt certificate:

```bash
finchctl service deploy \
--service.letsencrypt --service.letsencrypt.email acme@example.com \
--service.user admin --service.password secret \
--service.host finch.example.com root@cloud.machine
```

Services are exposed at `https://finch.example.com` with a Let's Encrypt
certificate. Credentials for Grafana and Finch: user `admin`, password `secret`.
certificate.

To use a custom TLS certificate:

Expand Down
9 changes: 2 additions & 7 deletions cmd/completion/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,8 @@ func CompleteStackName(cmd *cobra.Command, args []string, toComplete string) ([]
return nil, cobra.ShellCompDirectiveNoFileComp
}

config, err := config.ReadConfig()
stacks, err := config.ListStacks()
cobra.CheckErr(err)

var stackNames []string
for _, stack := range config.Stacks {
stackNames = append(stackNames, stack.Name)
}

return stackNames, cobra.ShellCompDirectiveNoFileComp
return stacks, cobra.ShellCompDirectiveNoFileComp
}
15 changes: 0 additions & 15 deletions cmd/service/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ Licensed under the MIT license, see LICENSE in the project root for details.
package service

import (
"crypto/rand"
"fmt"
"net/url"
"strings"
Expand All @@ -29,8 +28,6 @@ func init() {
deployCmd.Flags().String("run.format", "progress", "output format")
deployCmd.Flags().Bool("run.dry-run", false, "do not deploy, just print the commands that would be run")
deployCmd.Flags().String("service.host", "", "service host (default: auto-detected from target URL)")
deployCmd.Flags().String("service.user", "", "service user (default: 'admin')")
deployCmd.Flags().String("service.password", "", "service password (default: random password)")
deployCmd.Flags().Bool("service.letsencrypt", false, "use Let's Encrypt for TLS certificate (default: false)")
deployCmd.Flags().String("service.letsencrypt.email", "", "email address for Let's Encrypt registration (required if --service.letsencrypt is true)")
deployCmd.Flags().Bool("service.customtls", false, "use custom TLS certificate (default: false)")
Expand Down Expand Up @@ -76,18 +73,6 @@ func deployConfig(cmd *cobra.Command, args []string, formatType target.Format) (
}
config.Hostname = hostname

user, _ := cmd.Flags().GetString("service.user")
if user == "" {
user = "admin"
}
config.Username = user

password, _ := cmd.Flags().GetString("service.password")
if password == "" {
password = rand.Text()
}
config.Password = password

letsencrypt, _ := cmd.Flags().GetBool("service.letsencrypt")
letsencryptEmail, _ := cmd.Flags().GetString("service.letsencrypt.email")
customTLS, _ := cmd.Flags().GetBool("service.customtls")
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func raiseError() error {
return &DeployAgentError{Message: "deployment failed", Reason: "insufficient resources"}
}

func Test_convertError(t *testing.T) {
func Test_ConvertError(t *testing.T) {
err := raiseError()
assert.Error(t, err, "expected an error from raiseError function")
assert.IsType(t, &DeployAgentError{}, err, "expected error to be of type DeployAgentError")
Expand Down
158 changes: 98 additions & 60 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,84 +26,93 @@ const (
ConfigLocationEnv string = "FINCH_CONFIG"
)

type Config struct {
Stacks []Stack `json:"stacks"`
type Stacks struct {
List []Stack `json:"stacks"`
}

type Stack struct {
Name string `json:"name,omitempty"`
Token string `json:"token,omitempty"`
Name string `json:"name,omitempty"`
Cert string `json:"cert,omitempty"`
Key string `json:"key,omitempty"`
}

func UpdateStackAuth(name, username, password string) error {
var config Config
if fileExists() {
cfg, err := ReadConfig()
func UpdateStack(name string, certPEM, keyPEM []byte) error {
var stacks Stacks
if exist() {
if err := backup(); err != nil {
return &ConfigError{Message: "failed to backup config", Reason: err.Error()}
}

cfg, err := read()
if err != nil {
return err
return &ConfigError{Message: "failed to read config", Reason: err.Error()}
}
config = *cfg
stacks = *cfg
}

exists := false
for _, authConfig := range config.Stacks {
if authConfig.Name == name {
for _, stack := range stacks.List {
if stack.Name == name {
exists = true
break
}
}

if exists {
config.Stacks = slices.DeleteFunc(config.Stacks, func(s Stack) bool {
stacks.List = slices.DeleteFunc(stacks.List, func(s Stack) bool {
return s.Name == name
})
}

config.Stacks = append(config.Stacks, Stack{
Name: name,
Token: encodeToken(username, password),
stacks.List = append(stacks.List, Stack{
Name: name,
Cert: base64.StdEncoding.EncodeToString(certPEM),
Key: base64.StdEncoding.EncodeToString(keyPEM),
})

return WriteConfig(&config)
return write(&stacks)
}

func LookupStackAuth(name string) (string, error) {
if !fileExists() {
return "", &ConfigError{Message: "config file does not exist", Reason: ""}
func LookupStack(name string) ([]byte, []byte, error) {
if !exist() {
return nil, nil, &ConfigError{Message: "config file does not exist", Reason: ""}
}

config, err := ReadConfig()
stacks, err := read()
if err != nil {
return "", &ConfigError{Message: "failed to read config", Reason: err.Error()}
}

var token string
for _, authConfig := range config.Stacks {
if authConfig.Name == name {
token = authConfig.Token
break
return nil, nil, &ConfigError{Message: "failed to read config", Reason: err.Error()}
}

for _, stack := range stacks.List {
if stack.Name == name {
certPEM, err := base64.StdEncoding.DecodeString(stack.Cert)
if err != nil {
return nil, nil, &ConfigError{Message: "failed to decode certificate", Reason: err.Error()}
}
keyPEM, err := base64.StdEncoding.DecodeString(stack.Key)
if err != nil {
return nil, nil, &ConfigError{Message: "failed to decode key", Reason: err.Error()}
}
return certPEM, keyPEM, nil
}
}
if token == "" {
return "", &ConfigError{Message: "stack not found", Reason: ""}
}

return token, nil
return nil, nil, &ConfigError{Message: "stack not found", Reason: ""}
}

func RemoveStackAuth(name string) error {
if !fileExists() {
func RemoveStack(name string) error {
if !exist() {
return &ConfigError{Message: "config file does not exist", Reason: ""}
}

config, err := ReadConfig()
stacks, err := read()
if err != nil {
return &ConfigError{Message: "failed to read config", Reason: err.Error()}
}

index := -1
for i, authConfig := range config.Stacks {
if authConfig.Name == name {
for i, stack := range stacks.List {
if stack.Name == name {
index = i
break
}
Expand All @@ -113,43 +122,71 @@ func RemoveStackAuth(name string) error {
return nil
}

config.Stacks = slices.Delete(config.Stacks, index, index+1)
if err := backup(); err != nil {
return &ConfigError{Message: "failed to backup config", Reason: err.Error()}
}

return WriteConfig(config)
stacks.List = slices.Delete(stacks.List, index, index+1)

return write(stacks)
}

func fileExists() bool {
if _, err := os.Stat(configFile()); err != nil && os.IsNotExist(err) {
return false
func ListStacks() ([]string, error) {
if !exist() {
return nil, &ConfigError{Message: "config file does not exist", Reason: ""}
}

return true
stacks, err := read()
if err != nil {
return nil, &ConfigError{Message: "failed to read config", Reason: err.Error()}
}

var names []string
for _, stack := range stacks.List {
names = append(names, stack.Name)
}

return names, nil
}

func WriteConfig(config *Config) error {
data, err := json.MarshalIndent(config, "", " ")
func backup() error {
data, err := os.ReadFile(path())
if err != nil {
return &ConfigError{Message: err.Error(), Reason: ""}
return err
}

return os.WriteFile(configFile(), data, 0600)
err = os.WriteFile(fmt.Sprintf("%s~", path()), data, 0400)
if err != nil {
return err
}

return nil
}

func ReadConfig() (*Config, error) {
data, err := os.ReadFile(configFile())
func write(stacks *Stacks) error {
data, err := json.MarshalIndent(stacks, "", " ")
if err != nil {
return nil, &ConfigError{Message: err.Error(), Reason: ""}
return err
}

var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, &ConfigError{Message: err.Error(), Reason: ""}
return os.WriteFile(path(), data, 0600)
}

func read() (*Stacks, error) {
data, err := os.ReadFile(path())
if err != nil {
return nil, err
}

var stacks Stacks
if err := json.Unmarshal(data, &stacks); err != nil {
return nil, err
}

return &config, nil
return &stacks, nil
}

func configFile() string {
func path() string {
var dir string
dir = os.Getenv(ConfigLocationEnv)

Expand All @@ -165,9 +202,10 @@ func configFile() string {
return fmt.Sprintf("%s/finch.json", dir)
}

func encodeToken(username, password string) string {
plain := fmt.Sprintf("%s:%s", username, password)
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
func exist() bool {
if _, err := os.Stat(path()); err != nil && os.IsNotExist(err) {
return false
}

return encoded
return true
}
Loading