diff --git a/backend/internal/api/handler/hosts.go b/backend/internal/api/handler/hosts.go index 936621c..e4c6e11 100644 --- a/backend/internal/api/handler/hosts.go +++ b/backend/internal/api/handler/hosts.go @@ -78,7 +78,7 @@ func CreateHost() func(http.ResponseWriter, *http.Request) { return } - if err = newHost.Save(); err != nil { + if err = newHost.Save(false); err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, fmt.Sprintf("Unable to save Host: %s", err.Error()), nil) return } @@ -111,7 +111,7 @@ func UpdateHost() func(http.ResponseWriter, *http.Request) { return } - if err = hostObject.Save(); err != nil { + if err = hostObject.Save(false); err != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil) return } diff --git a/backend/internal/entity/host/model.go b/backend/internal/entity/host/model.go index 74dd208..c435cf4 100644 --- a/backend/internal/entity/host/model.go +++ b/backend/internal/entity/host/model.go @@ -6,6 +6,7 @@ import ( "npm/internal/database" "npm/internal/entity/certificate" + "npm/internal/entity/hosttemplate" "npm/internal/entity/user" "npm/internal/types" "npm/internal/util" @@ -56,8 +57,9 @@ type Model struct { IsDisabled bool `json:"is_disabled" db:"is_disabled" filter:"is_disabled,boolean"` IsDeleted bool `json:"is_deleted,omitempty" db:"is_deleted"` // Expansions - Certificate *certificate.Model `json:"certificate,omitempty"` - User *user.Model `json:"user,omitempty"` + Certificate *certificate.Model `json:"certificate,omitempty"` + HostTemplate *hosttemplate.Model `json:"host_template,omitempty"` + User *user.Model `json:"user,omitempty"` } func (m *Model) getByQuery(query string, params []interface{}) error { @@ -82,15 +84,17 @@ func (m *Model) Touch(created bool) { } // Save will save this model to the DB -func (m *Model) Save() error { +func (m *Model) Save(skipConfiguration bool) error { var err error if m.UserID == 0 { return fmt.Errorf("User ID must be specified") } - // Set this host as requiring reconfiguration - m.Status = StatusReady + if !skipConfiguration { + // Set this host as requiring reconfiguration + m.Status = StatusReady + } if m.ID == 0 { m.ID, err = create(m) @@ -105,7 +109,7 @@ func (m *Model) Save() error { func (m *Model) Delete() bool { m.Touch(false) m.IsDeleted = true - if err := m.Save(); err != nil { + if err := m.Save(false); err != nil { return false } return true @@ -127,5 +131,11 @@ func (m *Model) Expand(items []string) error { m.Certificate = &cert } + if util.SliceContainsItem(items, "hosttemplate") && m.HostTemplateID > 0 { + var templ hosttemplate.Model + templ, err = hosttemplate.GetByID(m.HostTemplateID) + m.HostTemplate = &templ + } + return err } diff --git a/backend/internal/nginx/control.go b/backend/internal/nginx/control.go index 000d976..07e8449 100644 --- a/backend/internal/nginx/control.go +++ b/backend/internal/nginx/control.go @@ -1,13 +1,51 @@ package nginx -import "npm/internal/entity/host" +import ( + "fmt" + + "npm/internal/config" + "npm/internal/entity/host" + "npm/internal/logger" +) // ConfigureHost will attempt to write nginx conf and reload nginx func ConfigureHost(h host.Model) error { // nolint: errcheck, gosec - h.Expand([]string{"certificate"}) + h.Expand([]string{"certificate", "hosttemplate"}) + + data := TemplateData{ + ConfDir: fmt.Sprintf("%s/nginx/hosts", config.Configuration.DataFolder), + DataDir: config.Configuration.DataFolder, + CertsDir: config.Configuration.Acmesh.CertHome, + Host: &h, + Certificate: h.Certificate, + } + + filename := fmt.Sprintf("%s/host_%d.conf", data.ConfDir, h.ID) + + // Write the config to disk + err := writeTemplate(filename, h.HostTemplate.Template, data) + if err != nil { + // this configuration failed somehow + h.Status = host.StatusError + h.ErrorMessage = fmt.Sprintf("Template generation failed: %s", err.Error()) + logger.Debug(h.ErrorMessage) + return h.Save(true) + } // nolint: errcheck, gosec - reloadNginx() - return nil + if err := reloadNginx(); err != nil { + // reloading nginx failed, likely due to this host having a problem + h.Status = host.StatusError + h.ErrorMessage = fmt.Sprintf("Nginx configuation error: %s", err.Error()) + writeConfigFile(filename, fmt.Sprintf("# %s", h.ErrorMessage)) + logger.Debug(h.ErrorMessage) + } else { + // All good + h.Status = host.StatusOK + h.ErrorMessage = "" + logger.Debug("ConfigureHost OK: %+v", h) + } + + return h.Save(true) } diff --git a/backend/internal/nginx/template_test.go b/backend/internal/nginx/template_test.go new file mode 100644 index 0000000..4aa2dee --- /dev/null +++ b/backend/internal/nginx/template_test.go @@ -0,0 +1,86 @@ +package nginx + +import ( + "testing" + + "npm/internal/entity/certificate" + "npm/internal/entity/host" + + "github.com/stretchr/testify/assert" +) + +func TestWriteTemplate(t *testing.T) { + template := ` +{{#if Host.IsDisabled}} + # Host is disabled +{{else}} +server { + {{#if Certificate}} + {{#if Certificate.CertificateAuthorityID}} + # Acme SSL + include {{ConfDir}}/npm/conf.d/acme-challenge.conf; + include {{ConfDir}}/npm/conf.d/include/ssl-ciphers.conf; + ssl_certificate {{CertsDir}}/npm-{{Certificate.ID}}/fullchain.pem; + ssl_certificate_key {{CertsDir}}/npm-{{Certificate.ID}}/privkey.pem; + {{else}} + # Custom SSL + ssl_certificate {{DataDir}}/custom_ssl/npm-{{Certificate.ID}}/fullchain.pem; + ssl_certificate_key {{DataDir}}/custom_ssl/npm-{{Certificate.ID}}/privkey.pem; + {{/if}} + {{/if}} +} +{{/if}} + +` + + type want struct { + output string + err error + } + + tests := []struct { + name string + data TemplateData + want want + }{ + { + name: "Basic Template enabled", + data: TemplateData{ + ConfDir: "/etc/nginx/conf.d", + Host: &host.Model{ + IsDisabled: false, + }, + Certificate: &certificate.Model{ + CertificateAuthorityID: 0, + }, + }, + want: want{ + output: "\nserver {\n # Custom SSL\n ssl_certificate /custom_ssl/npm-0/fullchain.pem;\n ssl_certificate_key /custom_ssl/npm-0/privkey.pem;\n \n}\n\n", + err: nil, + }, + }, + { + name: "Basic Template disabled", + data: TemplateData{ + ConfDir: "/etc/nginx/conf.d", + DataDir: "/data", + CertsDir: "/acme.sh/certs", + Host: &host.Model{ + IsDisabled: true, + }, + }, + want: want{ + output: "\n # Host is disabled\n\n", + err: nil, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(st *testing.T) { + output, err := generateHostConfig(template, test.data) + assert.Equal(t, test.want.err, err) + assert.Equal(t, test.want.output, output) + }) + } +} diff --git a/backend/internal/nginx/templates.go b/backend/internal/nginx/templates.go index 3c87b2b..bf4fc80 100644 --- a/backend/internal/nginx/templates.go +++ b/backend/internal/nginx/templates.go @@ -1,31 +1,46 @@ package nginx import ( - "io/fs" + "fmt" "io/ioutil" - "npm/embed" + "npm/internal/entity/certificate" + "npm/internal/entity/host" + "npm/internal/logger" "github.com/aymerick/raymond" ) -// WriteTemplate will load, parse and write a template file -func WriteTemplate(templateName, outputFilename string, data map[string]interface{}) error { - // get template file content - subFs, _ := fs.Sub(embed.NginxFiles, "nginx") - template, err := fs.ReadFile(subFs, templateName) - - if err != nil { - return err - } - - // Render - parsedFile, err := raymond.Render(string(template), data) - if err != nil { - return err - } - - // Write it - // nolint: gosec - return ioutil.WriteFile(outputFilename, []byte(parsedFile), 0644) +// TemplateData ... +type TemplateData struct { + ConfDir string + DataDir string + CertsDir string + Host *host.Model + Certificate *certificate.Model +} + +func generateHostConfig(template string, data TemplateData) (string, error) { + return raymond.Render(template, data) +} + +func writeTemplate(filename, template string, data TemplateData) error { + output, err := generateHostConfig(template, data) + if err != nil { + output = fmt.Sprintf("# Template Error: %s", err.Error()) + } + + // Write it. This will also write an error comment if generation failed + // nolint: gosec + writeErr := writeConfigFile(filename, output) + if err != nil { + return err + } + return writeErr +} + +func writeConfigFile(filename, content string) error { + logger.Debug("Writing %s with:\n%s", filename, content) + // nolint: gosec + return ioutil.WriteFile(filename, []byte(content), 0644) }