package main import ( "bufio" "bytes" "context" "crypto" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/x509" "embed" "encoding/base64" "encoding/json" "encoding/pem" "errors" "flag" "fmt" "html/template" "io" "io/fs" "log" "math" "math/big" "net" "net/http" "os" "os/exec" "path" "path/filepath" "reflect" "regexp" "runtime" "runtime/debug" "strconv" "strings" "time" "github.com/biz/templates" humanize "github.com/dustin/go-humanize" _ "github.com/go-sql-driver/mysql" "github.com/google/go-github/github" "github.com/gorilla/mux" "github.com/gorilla/securecookie" "github.com/gorilla/sessions" "github.com/gorilla/websocket" zxcvbn "github.com/nbutton23/zxcvbn-go" "github.com/spf13/viper" "golang.org/x/crypto/bcrypt" "golang.org/x/text/cases" "golang.org/x/text/language" ) const ( writeWait = 10 * time.Second pongWait = 60 * time.Second pingPeriod = (pongWait * 9) / 10 updateInterval = 24 * time.Hour ) var ( restartSecret string sessionStore *sessions.CookieStore tmpls *templates.Templates version string webTitle string dbConn string dbType string isDev bool updateAvailable bool updateChecked time.Time srv *http.Server configPath string listenAddress string //go:embed templates embeddedTemplates embed.FS //go:embed static staticFiles embed.FS // Is set by the compiler using -ldflags standaloneVersion string upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, } ) // User struct for storing the admin user account details. type User struct { Name string Email string Password string Confirm string NewPassword string RequestBase string Errors map[string]string } // ValidatePassword checks that the password of a User is non-empty, matches the confirmation, is not on the blacklist and is sufficiently complex. func (reg *User) ValidatePassword(isNew bool, isChange bool) { blacklist := []string{"labca", "acme", reg.Name} if x := strings.Index(reg.Email, "@"); x > 0 { blacklist = append(blacklist, reg.Email[:x]) d := strings.Split(reg.Email[x+1:], ".") for i := 0; i < len(d)-1; i++ { blacklist = append(blacklist, d[i]) } } if strings.TrimSpace(reg.Password) == "" { reg.Errors["Password"] = "Please enter a password" } else if isNew { strength := zxcvbn.PasswordStrength(reg.Password, blacklist).Score if strength < 1 { reg.Errors["Password"] = "Please pick a stronger, more secure password" } } if isNew { if strings.TrimSpace(reg.Confirm) == "" { reg.Errors["Confirm"] = "Please enter the password again" } else if strings.TrimSpace(reg.Confirm) != strings.TrimSpace(reg.Password) { reg.Errors["Confirm"] = "Passwords do not match!" } } if isChange { if strings.TrimSpace(reg.NewPassword) != "" { strength := zxcvbn.PasswordStrength(reg.NewPassword, blacklist).Score if strength < 1 { reg.Errors["NewPassword"] = "Please pick a stronger, more secure password" } if strings.TrimSpace(reg.Confirm) == "" { reg.Errors["Confirm"] = "Please enter the new password again" } else if strings.TrimSpace(reg.Confirm) != strings.TrimSpace(reg.NewPassword) { reg.Errors["Confirm"] = "New passwords do not match!" } } byteStored := []byte(viper.GetString("user.password")) err := bcrypt.CompareHashAndPassword(byteStored, []byte(reg.Password)) if err != nil { reg.Errors["Password"] = "Current password is not correct!" } } } // Validate that User struct contains at least a Name, has a valid email address and password fields. func (reg *User) Validate(isNew bool, isChange bool) bool { reg.Errors = make(map[string]string) if strings.TrimSpace(reg.Name) == "" { reg.Errors["Name"] = "Please enter a user name" } if isNew || isChange { re := regexp.MustCompile(`.+@.+\..+`) matched := re.Match([]byte(reg.Email)) if !matched { reg.Errors["Email"] = "Please enter a valid email address" } } reg.ValidatePassword(isNew, isChange) return len(reg.Errors) == 0 } // SetupConfig stores the basic config settings. type SetupConfig struct { Fqdn string Organization string WebTitle string DNS string DomainMode string LockdownDomains string WhitelistDomains string LDPublicContacts bool ExtendedTimeout bool RequestBase string Errors map[string]string } // Validate that SetupConfig contains all required data. func (cfg *SetupConfig) Validate(orgRequired bool) bool { cfg.Errors = make(map[string]string) if strings.TrimSpace(cfg.Fqdn) == "" { cfg.Errors["Fqdn"] = "Please enter the Fully Qualified Domain name for this host" } if strings.TrimSpace(cfg.Organization) == "" && orgRequired { cfg.Errors["Organization"] = "Please enter the organization name to show on the public pages" } if strings.TrimSpace(cfg.DNS) == "" { cfg.Errors["DNS"] = "Please enter the DNS server to be used for validation" } if cfg.DomainMode != "lockdown" && cfg.DomainMode != "whitelist" && cfg.DomainMode != "standard" { cfg.Errors["DomainMode"] = "Please select the domain mode to use" } if cfg.DomainMode == "lockdown" && strings.TrimSpace(cfg.LockdownDomains) == "" { cfg.Errors["LockdownDomains"] = "Please enter one or more domains that this PKI host is locked down to" } if cfg.DomainMode == "lockdown" && strings.HasPrefix(cfg.LockdownDomains, ".") { cfg.Errors["LockdownDomains"] = "Domain should not start with a dot" } if cfg.DomainMode == "whitelist" && strings.TrimSpace(cfg.WhitelistDomains) == "" { cfg.Errors["WhitelistDomains"] = "Please enter one or more domains that are whitelisted for this PKI host" } if cfg.DomainMode == "whitelist" && strings.HasPrefix(cfg.WhitelistDomains, ".") { cfg.Errors["WhitelistDomains"] = "Domain should not start with a dot" } return len(cfg.Errors) == 0 } // StandaloneConfig stores the config settings when running standalone. type StandaloneConfig struct { Backend string MySQLServer string MySQLPort string MySQLDBName string MySQLUser string MySQLPasswd string UseHTTPS bool CertPath string KeyPath string RequestBase string Errors map[string]string } // Validate that StandaloneConfig contains all required data. func (cfg *StandaloneConfig) Validate() bool { cfg.Errors = make(map[string]string) if strings.TrimSpace(cfg.Backend) != "step-ca" { cfg.Errors["Backend"] = "Currently only step-ca is supported as backend" } if strings.TrimSpace(cfg.MySQLServer) == "" { cfg.Errors["MySQLServer"] = "Please enter the name or IP address of the MySQL server" } _, err := strconv.Atoi(string(strings.TrimSpace(cfg.MySQLServer)[0])) if err == nil { if ip := net.ParseIP(strings.TrimSpace(cfg.MySQLServer)); ip == nil { cfg.Errors["MySQLServer"] = "Please enter a valid IP address" } } if strings.TrimSpace(cfg.MySQLPort) == "" { cfg.Errors["MySQLPort"] = "Please enter the port number of the MySQL server" } p, err := strconv.Atoi(strings.TrimSpace(cfg.MySQLPort)) if err != nil || p < 1 || p > 65535 { cfg.Errors["MySQLPort"] = "Please enter a valid port number" } if strings.TrimSpace(cfg.MySQLDBName) == "" { cfg.Errors["MySQLDBName"] = "Please enter the name of the MySQL database" } if strings.TrimSpace(cfg.MySQLUser) == "" { cfg.Errors["MySQLUser"] = "Please enter the name of the MySQL user" } if strings.TrimSpace(cfg.MySQLPasswd) == "" { cfg.Errors["MySQLPasswd"] = "Please enter the password of the MySQL user" } if cfg.UseHTTPS && strings.TrimSpace(cfg.CertPath) == "" { cfg.Errors["CertPath"] = "Please enter the location and name of the HTTPS certificate to use" } if cfg.UseHTTPS && strings.TrimSpace(cfg.KeyPath) == "" { cfg.Errors["KeyPath"] = "Please enter the location and name of the HTTPS key file to use" } return len(cfg.Errors) == 0 } func errorHandler(w http.ResponseWriter, r *http.Request, err error, status int) { log.Printf("errorHandler: err=%v\n", err) w.WriteHeader(status) pc := make([]uintptr, 15) n := runtime.Callers(2, pc) frames := runtime.CallersFrames(pc[:n]) frame, _ := frames.Next() //fmt.Printf("%s:%d, %s\n", frame.File, frame.Line, frame.Function) if frame.Function == "main.render" { _, _ = fmt.Fprintf(w, "Could not render requested page") return } if status == http.StatusNotFound { render(w, r, "error", map[string]interface{}{"Message": "That page does not exist"}) } else { lines := strings.Split(string(debug.Stack()), "\n") if len(lines) >= 5 { lines = append(lines[:0], lines[5:]...) } fmt.Print(strings.Join(lines, "\n")) if viper.GetBool("config.complete") { render(w, r, "error", map[string]interface{}{"Message": "Some unexpected error occurred!"}) } else { // ONLY in the setup phase to prevent leaking too much details to users var FileErrors []interface{} data := getLog(w, r, "cert") if data != "" { FileErrors = append(FileErrors, map[string]interface{}{"FileName": "/home/labca/nginx_data/ssl/certbot.log", "Content": data}) } data = getLog(w, r, "commander") if data != "" { FileErrors = append(FileErrors, map[string]interface{}{"FileName": "(control)/logs/commander.log", "Content": data}) } data = getLog(w, r, "control-notail") if data != "" { FileErrors = append(FileErrors, map[string]interface{}{"FileName": "docker compose logs control", "Content": data}) } data = getLog(w, r, "boulder-notail") if data != "" { FileErrors = append(FileErrors, map[string]interface{}{"FileName": "docker compose logs boulder", "Content": data}) } data = getLog(w, r, "labca-notail") if data != "" { FileErrors = append(FileErrors, map[string]interface{}{"FileName": "docker compose logs labca", "Content": data}) } render(w, r, "error", map[string]interface{}{"Message": "Some unexpected error occurred!", "FileErrors": FileErrors}) } } } func checkUpdates(forced bool) ([]string, []string) { var versions []string var descriptions []string if forced || updateChecked.Add(updateInterval).Before(time.Now()) { latest := "" newer := true client := github.NewClient(nil) if releases, _, err := client.Repositories.ListReleases(context.Background(), "hakwerk", "labca", nil); err == nil { for i := 0; i < len(releases); i++ { release := releases[i] if !*release.Draft { if !*release.Prerelease || isDev { if latest == "" { latest = *release.Name } if *release.Name == version { newer = false } if latest == *release.Name && strings.HasPrefix(version, *release.Name+"-") { // git describe format newer = false latest = version } if newer { versions = append(versions, *release.Name) descriptions = append(descriptions, *release.Body) } } } } updateChecked = time.Now() updateAvailable = (len(releases) > 0) && (latest != version) } } return versions, descriptions } func rootHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } dashboardData, err := CollectDashboardData(w, r) if err == nil { if viper.GetBool("standalone") { dashboardData["UpdateAvailable"] = false } else { checkUpdates(false) dashboardData["UpdateAvailable"] = updateAvailable } dashboardData["UpdateChecked"] = strings.ReplaceAll(updateChecked.Format("02-Jan-2006 15:04:05 MST"), "+0000", "GMT") dashboardData["UpdateCheckedRel"] = humanize.RelTime(updateChecked, time.Now(), "", "") render(w, r, "dashboard", dashboardData) } } func aboutHandler(w http.ResponseWriter, r *http.Request) { render(w, r, "about", map[string]interface{}{ "Title": "About", "Standalone": viper.GetBool("standalone"), }) } func loginHandler(w http.ResponseWriter, r *http.Request) { if viper.Get("user.password") == nil { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } session, _ := sessionStore.Get(r, "labca") var bounceURL string if session.Values["bounce"] == nil { bounceURL = "/" } else { bounceURL = session.Values["bounce"].(string) } if session.Values["user"] != nil { http.Redirect(w, r, r.Header.Get("X-Request-Base")+bounceURL, http.StatusFound) return } switch r.Method { case "GET": reg := &User{ RequestBase: r.Header.Get("X-Request-Base"), } render(w, r, "login", map[string]interface{}{"User": reg, "IsLogin": true}) return case "POST": if err := r.ParseForm(); err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return } reg := &User{ Name: r.Form.Get("username"), Password: r.Form.Get("password"), RequestBase: r.Header.Get("X-Request-Base"), } if !reg.Validate(false, false) { render(w, r, "login", map[string]interface{}{"User": reg, "IsLogin": true}) return } if viper.GetString("user.name") != reg.Name { reg.Errors["Name"] = "Incorrect username or password" render(w, r, "login", map[string]interface{}{"User": reg, "IsLogin": true}) return } byteStored := []byte(viper.GetString("user.password")) err := bcrypt.CompareHashAndPassword(byteStored, []byte(reg.Password)) if err != nil { log.Println(err) reg.Errors["Name"] = "Incorrect username or password" render(w, r, "login", map[string]interface{}{"User": reg, "IsLogin": true}) return } session.Values["user"] = reg.Name if err = session.Save(r, w); err != nil { log.Printf("cannot save session: %s\n", err) } http.Redirect(w, r, r.Header.Get("X-Request-Base")+bounceURL, http.StatusFound) default: http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/login", http.StatusSeeOther) return } } func logoutHandler(w http.ResponseWriter, r *http.Request) { session, _ := sessionStore.Get(r, "labca") session.Options.MaxAge = -1 if err := session.Save(r, w); err != nil { log.Printf("cannot save session: %s\n", err) } http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/", http.StatusFound) } func _sendCmdOutput(w http.ResponseWriter, r *http.Request, cmd string) { parts := strings.Fields(cmd) for i := 0; i < len(parts); i++ { parts[i] = strings.ReplaceAll(parts[i], "\\\\", " ") } head := parts[0] parts = parts[1:] out, err := exec.Command(head, parts...).Output() if err != nil { fmt.Println(err) fmt.Println(string(out)) errorHandler(w, r, err, http.StatusInternalServerError) return } buf := bytes.NewBuffer(out) _, err = buf.WriteTo(w) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return } } func _backupHandler(w http.ResponseWriter, r *http.Request) { res := struct { Success bool Message string }{Success: true} action := r.Form.Get("action") switch action { case "backup-restore": backup := r.Form.Get("backup") if !_hostCommand(w, r, action, backup) { res.Success = false res.Message = "Command failed - see LabCA log for any details" } else { defer func() { _hostCommand(w, r, "server-restart") if _, err := exeCmd("./restart_control"); err != nil { log.Printf("_backupHandler: error restarting control container: %v", err) } }() } case "backup-delete": backup := r.Form.Get("backup") if !_hostCommand(w, r, action, backup) { res.Success = false res.Message = "Command failed - see LabCA log for any details" } case "backup-now": res.Message = getLog(w, r, "server-backup") if res.Message == "" { res.Success = false res.Message = "Command failed - see LabCA log for any details" } else { res.Message = filepath.Base(res.Message) } case "backup-upload": file, header, err := r.FormFile("backup-file") if err != nil { fmt.Println(err) res.Success = false res.Message = "Could not read uploaded file" } var out *os.File if res.Success { defer func() { _ = file.Close() }() out, err = os.Create("/opt/backup/" + header.Filename) if err != nil { fmt.Println(err) res.Success = false res.Message = "Could not create backup file on server" } } if res.Success { defer func() { _ = out.Close() }() _, copyError := io.Copy(out, file) if copyError != nil { fmt.Println(err) res.Success = false res.Message = "Could not store uploaded file" } else { res.Message = header.Filename } } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } type ErrorsResponse struct { Success bool Errors map[string]string } func makeErrorsResponse(success bool) ErrorsResponse { return ErrorsResponse{Success: success, Errors: make(map[string]string)} } func _accountUpdateHandler(w http.ResponseWriter, r *http.Request) { reg := &User{ Name: r.Form.Get("username"), Email: r.Form.Get("email"), NewPassword: r.Form.Get("new-password"), Confirm: r.Form.Get("confirm"), Password: r.Form.Get("password"), } res := makeErrorsResponse(true) if reg.Validate(false, true) { viper.Set("user.name", reg.Name) viper.Set("user.email", reg.Email) if reg.NewPassword != "" { hash, err := bcrypt.GenerateFromPassword([]byte(reg.NewPassword), bcrypt.MinCost) if err != nil { res.Success = false errorHandler(w, r, err, http.StatusInternalServerError) return } viper.Set("user.password", string(hash)) // Forget current session, so user has to login with the new password session, _ := sessionStore.Get(r, "labca") session.Options.MaxAge = -1 if err = session.Save(r, w); err != nil { log.Printf("cannot save session: %s\n", err) } } _ = viper.WriteConfig() } else { res.Success = false res.Errors = reg.Errors } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } func backendUpdateHandler(w http.ResponseWriter, r *http.Request) { cfg := &StandaloneConfig{ Backend: r.Form.Get("backend"), MySQLServer: r.Form.Get("mysql_server"), MySQLPort: r.Form.Get("mysql_port"), MySQLDBName: r.Form.Get("mysql_dbname"), MySQLUser: r.Form.Get("mysql_user"), MySQLPasswd: r.Form.Get("mysql_passwd"), UseHTTPS: (r.Form.Get("use_https") == "https"), CertPath: r.Form.Get("cert_path"), KeyPath: r.Form.Get("key_path"), RequestBase: r.Header.Get("X-Request-Base"), } res := makeErrorsResponse(true) if cfg.Validate() { writeStandaloneConfig(cfg) } else { res.Success = false res.Errors = cfg.Errors } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } func _configUpdateHandler(w http.ResponseWriter, r *http.Request) { cfg := &SetupConfig{ Fqdn: r.Form.Get("fqdn"), Organization: r.Form.Get("organization"), WebTitle: r.Form.Get("webtitle"), DNS: r.Form.Get("dns"), DomainMode: r.Form.Get("domain_mode"), LockdownDomains: r.Form.Get("lockdown_domains"), WhitelistDomains: r.Form.Get("whitelist_domains"), LDPublicContacts: (r.Form.Get("ld_public_contacts") == "true"), ExtendedTimeout: (r.Form.Get("extended_timeout") == "true"), } res := makeErrorsResponse(true) if cfg.Validate(true) { delta := false deltaFQDN := false if cfg.Fqdn != viper.GetString("labca.fqdn") { delta = true deltaFQDN = true viper.Set("labca.fqdn", cfg.Fqdn) } if cfg.Organization != viper.GetString("labca.organization") { delta = true viper.Set("labca.organization", cfg.Organization) } if cfg.WebTitle != viper.GetString("labca.web_title") { delta = true viper.Set("labca.web_title", cfg.WebTitle) } matched, err := regexp.MatchString(":\\d+$", cfg.DNS) if err == nil && !matched { cfg.DNS += ":53" } if cfg.DNS != viper.GetString("labca.dns") { delta = true viper.Set("labca.dns", cfg.DNS) } domainMode := cfg.DomainMode if domainMode != viper.GetString("labca.domain_mode") { delta = true viper.Set("labca.domain_mode", cfg.DomainMode) } if domainMode == "lockdown" { if cfg.LockdownDomains != viper.GetString("labca.lockdown") { delta = true viper.Set("labca.lockdown", cfg.LockdownDomains) } if cfg.LDPublicContacts != viper.GetBool("labca.ld_public_contacts") { delta = true viper.Set("labca.ld_public_contacts", cfg.LDPublicContacts) } } if domainMode == "whitelist" { if cfg.WhitelistDomains != viper.GetString("labca.whitelist") { delta = true viper.Set("labca.whitelist", cfg.WhitelistDomains) } } extendedTimeout := cfg.ExtendedTimeout if extendedTimeout != viper.GetBool("labca.extended_timeout") { delta = true viper.Set("labca.extended_timeout", cfg.ExtendedTimeout) } if delta { _ = viper.WriteConfig() webTitle = viper.GetString("labca.web_title") if webTitle == "" { webTitle = "LabCA" } err := _applyConfig() if err != nil { res.Success = false res.Errors = cfg.Errors res.Errors["ConfigUpdate"] = "Config apply error: '" + err.Error() + "'" } else if deltaFQDN { if !_hostCommand(w, r, "acme-change", viper.GetString("labca.fqdn")) { res.Success = false res.Errors = cfg.Errors res.Errors["ConfigUpdate"] = "Error requesting certificate for new fqdn" } } } else { res.Success = false res.Errors = cfg.Errors res.Errors["ConfigUpdate"] = "Nothing changed!" } } else { res.Success = false res.Errors = cfg.Errors } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } func _crlIntervalUpdateHandler(w http.ResponseWriter, r *http.Request) { res := makeErrorsResponse(true) delta := false crlInterval := r.Form.Get("crl_interval") ci, err := time.ParseDuration(crlInterval) if err != nil { res.Success = false res.Errors["CRLInterval"] = "Could not parse duration" } else { back := 4 * ci crlInterval += "|" + back.String() if crlInterval != viper.GetString("crl_interval") { delta = true viper.Set("crl_interval", crlInterval) } if delta { _ = viper.WriteConfig() err := _applyConfig() if err != nil { res.Success = false res.Errors["CRLInterval"] = "Config apply error: '" + err.Error() + "'" } else if !_hostCommand(w, r, "boulder-restart") { res.Success = false res.Errors["CRLInterval"] = "Error restarting Boulder (ACME)" } } else { res.Success = false res.Errors["CRLInterval"] = "Nothing changed!" } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } func _exportHandler(w http.ResponseWriter, r *http.Request) { certname := r.Form.Get("certname") certFile := fmt.Sprintf("%s%s.pem", CERT_FILES_PATH, certname) seqnr := "" re := regexp.MustCompile(`-(\d{2})-`) match := re.FindStringSubmatch(certname) if len(match) > 1 { seqnr = match[1] } else { errorHandler(w, r, fmt.Errorf("failed to extract sequence number from filename '%s'", certFile), http.StatusInternalServerError) return } cfg := &HSMConfig{} if strings.HasPrefix(certname, "root-") { cfg.Initialize("root", seqnr) } if strings.HasPrefix(certname, "issuer-") { cfg.Initialize("issuer", seqnr) } key, err := cfg.GetPrivateKey() if err != nil { fmt.Println(err) if strings.Contains(err.Error(), "CKR_KEY_UNEXTRACTABLE") { errorHandler(w, r, err, http.StatusBadRequest) } else { errorHandler(w, r, err, http.StatusInternalServerError) } return } tmpDir, err := os.MkdirTemp("", "labca") if err != nil { fmt.Println(err) errorHandler(w, r, err, http.StatusInternalServerError) return } defer func() { _ = os.RemoveAll(tmpDir) }() keyFile := path.Join(tmpDir, fmt.Sprintf("%s.pem", strings.ReplaceAll(certname, "-cert", "-key"))) keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}) err = os.WriteFile(keyFile, keyPEM, os.ModeAppend) if err != nil { fmt.Println(err) errorHandler(w, r, err, http.StatusInternalServerError) return } if r.Form.Get("type") == "pfx" { w.Header().Set("Content-Type", "application/x-pkcs12") w.Header().Set("Content-Disposition", "attachment; filename=labca-"+certname+".pfx") cmd := "openssl pkcs12 -export -inkey " + keyFile + " -in " + certFile + " -passout pass:" + r.Form.Get("export-pwd") _sendCmdOutput(w, r, cmd) } if r.Form.Get("type") == "zip" { w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", "attachment; filename=labca-"+certname+".zip") cmd := "zip -j -P " + r.Form.Get("export-pwd") + " - " + keyFile + " " + certFile _sendCmdOutput(w, r, cmd) } } /* func _doCmdOutput(w http.ResponseWriter, r *http.Request, cmd string) string { parts := strings.Fields(cmd) for i := 0; i < len(parts); i++ { parts[i] = strings.ReplaceAll(parts[i], "\\\\", " ") } head := parts[0] parts = parts[1:] out, err := exec.Command(head, parts...).Output() if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return "" } return string(out) } */ /* func _encrypt(plaintext []byte) (string, error) { key := []byte(viper.GetString("keys.enc")) block, err := aes.NewCipher(key[:32]) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonce := make([]byte, gcm.NonceSize()) _, err = io.ReadFull(rand.Reader, nonce) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, plaintext, nil)), nil } */ func _decrypt(ciphertext string) ([]byte, error) { key := []byte(viper.GetString("keys.enc")) block, err := aes.NewCipher(key[:32]) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } ct, err := base64.StdEncoding.DecodeString(ciphertext) if err != nil { return nil, err } if len(ct) < gcm.NonceSize() { return nil, errors.New("malformed ciphertext") } return gcm.Open(nil, ct[:gcm.NonceSize()], ct[gcm.NonceSize():], nil) } // Result contains data on managed processes type Result struct { Success bool Message string Timestamp string TimestampRel string Class string } // ManageComponents sets the additional data to be displayed on the page for the LabCA components func (res *Result) ManageComponents(w http.ResponseWriter, r *http.Request, action string) { components := _parseComponents(getLog(w, r, "components")) for i := 0; i < len(components); i++ { if (components[i].Name == "NGINX Webserver" && (action == "nginx-reload" || action == "nginx-restart")) || (components[i].Name == "LabCA Controller" && action == "svc-restart") || (components[i].Name == "Boulder (ACME)" && (action == "boulder-start" || action == "boulder-stop" || action == "boulder-restart")) || (components[i].Name == "LabCA Application" && action == "labca-restart") || (components[i].Name == "consul (Boulder)" && action == "consul-restart") || (components[i].Name == "pkimetal (Boulder)" && action == "pkimetal-restart") || (components[i].Name == "redis (Boulder)" && action == "redis-restart") || (components[i].Name == "MySQL Database" && action == "mysql-restart") { res.Timestamp = components[i].Timestamp res.TimestampRel = components[i].TimestampRel res.Class = components[i].Class break } } } func _checkUpdatesHandler(w http.ResponseWriter, _ *http.Request) { res := struct { Success bool UpdateAvailable bool UpdateChecked string UpdateCheckedRel string Versions []string Descriptions []string Errors map[string]string }{Success: true, Errors: make(map[string]string)} res.Versions, res.Descriptions = checkUpdates(true) res.UpdateAvailable = updateAvailable res.UpdateChecked = updateChecked.Format("02-Jan-2006 15:04:05 MST") res.UpdateChecked = strings.ReplaceAll(res.UpdateChecked, "+0000", "GMT") res.UpdateCheckedRel = humanize.RelTime(updateChecked, time.Now(), "", "") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } func generateCRLHandler(w http.ResponseWriter, r *http.Request, isRoot bool) { res := makeErrorsResponse(true) command := "gen-issuer-crl" if isRoot { command = "gen-root-crl" } if !_hostCommand(w, r, command) { res.Success = false res.Errors["CRL"] = "Failed to generate CRL - see logs" } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } func uploadCRLHandler(w http.ResponseWriter, r *http.Request) { res := makeErrorsResponse(true) rootci := &CertificateInfo{ IsRoot: true, CRL: r.Form.Get("crl"), } if !rootci.StoreCRL("data/") { res.Success = false res.Errors["CRL"] = rootci.Errors["Modal"] } _hostCommand(w, r, "check-crl") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } func updateLeaveIssuersHandler(w http.ResponseWriter, r *http.Request) { res := struct { Success bool Error string }{Success: true} if err := setUseForLeaves(r.Form.Get("active")); err != nil { res.Success = false res.Error = err.Error() } else { defer func() { if !_hostCommand(w, r, "boulder-restart") { log.Printf("updateLeaveIssuersHandler: error restarting boulder: %v", err) } }() } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } func renewCertHandler(w http.ResponseWriter, r *http.Request) { res := struct { Success bool Error string }{Success: true} days, err := strconv.Atoi(r.Form.Get("days")) if err != nil { fmt.Printf("'%v' is not a number", r.Form.Get("days")) errorHandler(w, r, err, http.StatusBadRequest) return } if err := renewCertificate(r.Form.Get("certname"), days, r.Form.Get("rootname"), r.Form.Get("root_key"), r.Form.Get("passphrase")); err != nil { res.Success = false res.Error = err.Error() } else { ex, _ := os.Executable() exePath := filepath.Dir(ex) path, _ := filepath.Abs(exePath + "/..") if _, err := exeCmd(path + "/apply"); err != nil { fmt.Println(err) res.Success = false res.Error = "Could not apply: " + err.Error() } if !_hostCommand(w, r, "boulder-restart") { res.Success = false res.Error = "Error restarting Boulder (ACME)" } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } func _managePostDispatch(w http.ResponseWriter, r *http.Request, action string) bool { if action == "backup-restore" || action == "backup-delete" || action == "backup-now" || action == "backup-upload" { _backupHandler(w, r) return true } if action == "cert-export" { _exportHandler(w, r) return true } if action == "update-account" { _accountUpdateHandler(w, r) return true } if action == "update-backend" { backendUpdateHandler(w, r) return true } if action == "update-config" { _configUpdateHandler(w, r) return true } if action == "update-crl-interval" { _crlIntervalUpdateHandler(w, r) return true } if action == "version-check" { _checkUpdatesHandler(w, r) return true } if action == "upload-root-crl" { uploadCRLHandler(w, r) return true } if action == "gen-root-crl" { generateCRLHandler(w, r, true) return true } if action == "gen-issuer-crl" { generateCRLHandler(w, r, false) return true } if action == "update-leave-issuers" { updateLeaveIssuersHandler(w, r) return true } if action == "renew-cert" { renewCertHandler(w, r) return true } if action == "svc-restart" { if _, err := exeCmd("./restart_control"); err != nil { log.Printf("_managePostDispatch: error restarting control container: %v", err) } return true } return false } func _managePost(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return } action := r.Form.Get("action") if action == "" { if err := r.ParseMultipartForm(2 * 1024 * 1024); err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return } action = r.Form.Get("action") } actionKnown := false for _, a := range []string{ "backup-restore", "backup-delete", "backup-now", "backup-upload", "cert-export", "mysql-restart", "consul-restart", "pkimetal-restart", "redis-restart", "nginx-reload", "nginx-restart", "svc-restart", "boulder-start", "boulder-stop", "boulder-restart", "labca-restart", "server-restart", "update-account", "update-backend", "update-config", "update-crl-interval", "version-check", "version-update", "upload-root-crl", "gen-root-crl", "gen-issuer-crl", "update-leave-issuers", "renew-cert", } { if a == action { actionKnown = true } } if !actionKnown { errorHandler(w, r, fmt.Errorf("unknown manage action '%s'", action), http.StatusBadRequest) return } if _managePostDispatch(w, r, action) { return } res := &Result{Success: true} if !viper.GetBool("standalone") { if !_hostCommand(w, r, action) { res.Success = false res.Message = "Command failed - see LabCA log for any details" } if action != "server-restart" && action != "version-update" { res.ManageComponents(w, r, action) } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } func _manageGet(w http.ResponseWriter, r *http.Request) { manageData := make(map[string]interface{}) manageData["RequestBase"] = r.Header.Get("X-Request-Base") if viper.GetBool("standalone") { manageData["Standalone"] = true manageData["Backend"] = viper.GetString("backend") dsn := strings.Split(viper.GetString("db.conn"), "@") if len(dsn) > 0 { up := strings.Split(dsn[0], ":") if len(up) > 0 { manageData["MySQLUser"] = up[0] } if len(up) > 1 { manageData["MySQLPasswd"] = up[1] } } if len(dsn) > 1 { sd := strings.Split(dsn[1], "/") if len(sd) > 0 { if strings.HasPrefix(sd[0], "tcp(") { sd[0] = sd[0][4 : len(sd[0])-1] } sp := strings.Split(sd[0], ":") if len(sp) > 0 { manageData["MySQLServer"] = sp[0] } if len(sp) > 1 { manageData["MySQLPort"] = sp[1] } } if len(sd) > 1 { manageData["MySQLDBName"] = sd[1] } } manageData["UseHTTPS"] = viper.GetBool("server.https") manageData["CertPath"] = viper.GetString("server.cert") manageData["KeyPath"] = viper.GetString("server.key") } else { checkUpdates(false) manageData["UpdateAvailable"] = updateAvailable manageData["UpdateChecked"] = strings.ReplaceAll(updateChecked.Format("02-Jan-2006 15:04:05 MST"), "+0000", "GMT") manageData["UpdateCheckedRel"] = humanize.RelTime(updateChecked, time.Now(), "", "") components := _parseComponents(getLog(w, r, "components")) for i := 0; i < len(components); i++ { if components[i].Name == "Boulder (ACME)" { components[i].LogURL = r.Header.Get("X-Request-Base") + "/logs/boulder" components[i].LogTitle = "ACME Log" btn := make(map[string]interface{}) cls := "btn-success" if components[i].TimestampRel != "stopped" { cls = cls + " hidden" } btn["Class"] = cls btn["Id"] = "boulder-start" btn["Title"] = "Start the core ACME application" btn["Label"] = "Start" components[i].Buttons = append(components[i].Buttons, btn) btn = make(map[string]interface{}) cls = "btn-warning" if components[i].TimestampRel == "stopped" { cls = cls + " hidden" } btn["Class"] = cls btn["Id"] = "boulder-restart" btn["Title"] = "Stop and restart the core ACME application" btn["Label"] = "Restart" components[i].Buttons = append(components[i].Buttons, btn) btn = make(map[string]interface{}) cls = "btn-danger" if components[i].TimestampRel == "stopped" { cls = cls + " hidden" } btn["Class"] = cls btn["Id"] = "boulder-stop" btn["Title"] = "Stop the core ACME application; users can no longer use ACME clients to interact with this instance" btn["Label"] = "Stop" components[i].Buttons = append(components[i].Buttons, btn) } if components[i].Name == "LabCA Controller" { components[i].LogURL = "" components[i].LogTitle = "" btn := make(map[string]interface{}) btn["Class"] = "btn-warning" btn["Id"] = "svc-restart" btn["Title"] = "Restart the host service" btn["Label"] = "Restart" components[i].Buttons = append(components[i].Buttons, btn) } if components[i].Name == "LabCA Application" { components[i].LogURL = r.Header.Get("X-Request-Base") + "/logs/labca" components[i].LogTitle = "LabCA Log" btn := make(map[string]interface{}) btn["Class"] = "btn-warning" btn["Id"] = "labca-restart" btn["Title"] = "Stop and restart this LabCA admin application" btn["Label"] = "Restart" components[i].Buttons = append(components[i].Buttons, btn) } if components[i].Name == "NGINX Webserver" { components[i].LogURL = r.Header.Get("X-Request-Base") + "/logs/web" components[i].LogTitle = "Web Error Log" btn := make(map[string]interface{}) btn["Class"] = "btn-info" btn["Id"] = "nginx-reload" btn["Title"] = "Reload web server configuration with minimal impact to the users" btn["Label"] = "Reload" components[i].Buttons = append(components[i].Buttons, btn) btn = make(map[string]interface{}) btn["Class"] = "btn-warning" btn["Id"] = "nginx-restart" btn["Title"] = "Restart the web server with some downtime for the users" btn["Label"] = "Restart" components[i].Buttons = append(components[i].Buttons, btn) } if components[i].Name == "consul (Boulder)" { components[i].LogURL = "" components[i].LogTitle = "" btn := make(map[string]interface{}) btn["Class"] = "btn-warning" btn["Id"] = "consul-restart" btn["Title"] = "Restart the Consul internal DNS helper" btn["Label"] = "Restart" components[i].Buttons = append(components[i].Buttons, btn) } if components[i].Name == "pkimetal (Boulder)" { components[i].LogURL = "" components[i].LogTitle = "" btn := make(map[string]interface{}) btn["Class"] = "btn-warning" btn["Id"] = "pkimetal-restart" btn["Title"] = "Restart the internal pkimetal helper" btn["Label"] = "Restart" components[i].Buttons = append(components[i].Buttons, btn) } if components[i].Name == "redis (Boulder)" { components[i].LogURL = "" components[i].LogTitle = "" btn := make(map[string]interface{}) btn["Class"] = "btn-warning" btn["Id"] = "redis-restart" btn["Title"] = "Restart the internal redis helper" btn["Label"] = "Restart" components[i].Buttons = append(components[i].Buttons, btn) } if components[i].Name == "MySQL Database" { components[i].LogURL = "" components[i].LogTitle = "" btn := make(map[string]interface{}) btn["Class"] = "btn-warning" btn["Id"] = "mysql-restart" btn["Title"] = "Restart the MySQL database server" btn["Label"] = "Restart" components[i].Buttons = append(components[i].Buttons, btn) } } manageData["Components"] = components backupFiles := strings.Split(getLog(w, r, "backups"), "\n") backupFiles = backupFiles[:len(backupFiles)-1] manageData["BackupFiles"] = backupFiles chains := getChains() manageData["CertificateChains"] = chains if viper.Get("crl_interval") == nil || viper.GetString("crl_interval") == "" { manageData["CRLInterval"] = "24h" } else { ci := strings.Split(viper.GetString("crl_interval"), "|") manageData["CRLInterval"] = ci[0] } manageData["Fqdn"] = viper.GetString("labca.fqdn") manageData["Organization"] = viper.GetString("labca.organization") if viper.Get("labca.web_title") == nil || viper.GetString("labca.web_title") == "" { manageData["WebTitle"] = "LabCA" } else { manageData["WebTitle"] = viper.GetString("labca.web_title") } manageData["DNS"] = viper.GetString("labca.dns") domainMode := viper.GetString("labca.domain_mode") manageData["DomainMode"] = domainMode if domainMode == "lockdown" { manageData["LockdownDomains"] = viper.GetString("labca.lockdown") manageData["LDPublicContacts"] = viper.GetBool("labca.ld_public_contacts") } if domainMode == "whitelist" { manageData["WhitelistDomains"] = viper.GetString("labca.whitelist") } manageData["ExtendedTimeout"] = viper.GetBool("labca.extended_timeout") } manageData["Name"] = viper.GetString("user.name") manageData["Email"] = viper.GetString("user.email") manageData["Title"] = "Manage" render(w, r, "manage", manageData) } func manageHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } if r.Method == "POST" { _managePost(w, r) } else { _manageGet(w, r) } } /* func manageNewRootHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } // TODO: dynamically determine next filename (root-ca-2, root-ca-3, etc.) if !_certCreate(w, r, "root-ca-3", true) { // Cleanup the cert (if it even exists) so we will retry on the next run if _, err := os.Stat("data/root-ca-3.pem"); !errors.Is(err, fs.ErrNotExist) { exeCmd("mv data/root-ca-3.pem data/root-ca-3.pem_TMP") } return } // TODO: actually add the newly created key to the relevant config files (ca-a, ca-b, wfe2, possibly others) // TODO: reload boulder! http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/manage#certs", http.StatusSeeOther) } func manageNewIssuerHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } // TODO: dynamically determine next filename (ca-int-2, ca-int-3, etc.) // Is revertroot at all relevant in this scenario? if !_certCreate(w, r, "ca-int-3", false) { // Cleanup the cert (if it even exists) so we will retry on the next run os.Remove("data/issuer/ca-int-3.pem") return } // TODO: actually add the newly created key to the relevant config files (ca-a, ca-b, wfe2, possibly others) // TODO: reload boulder! http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/manage#certs", http.StatusSeeOther) } */ func logsHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) logType := vars["type"] proto := "ws" if r.Header.Get("X-Forwarded-Proto") == "https" { proto = "wss" } wsurl := proto + "://" + r.Host + r.Header.Get("X-Request-Base") + "/ws?logType=" + logType var name string var message string var data string switch logType { case "cert": name = "Web Certificate Log" message = "Log file for the certificate renewal for this server." wsurl = "" data = getLog(w, r, logType) case "boulder": name = "ACME Backend Log" message = "Live view on the backend ACME application (Boulder) logs." case "audit": name = "ACME Audit Log" message = "Live view on only the audit messages in the backend ACME application (Boulder) logs." case "cron": name = "Cron Log" message = "Live view on the logs for the cron jobs for LabCA." case "labca": name = "LabCA Log" message = "Live view on the logs for this LabCA web application." case "web": name = "Web Access Log" message = "Live view on the NGINX web server access log." default: errorHandler(w, r, fmt.Errorf("unknown log type '%s'", logType), http.StatusBadRequest) return } render(w, r, "logs", map[string]interface{}{ "Name": name, "Message": message, "Data": data, "WsUrl": wsurl, "Title": "Logs", }) } func getLog(w http.ResponseWriter, r *http.Request, logType string) string { conn, err := net.Dial("tcp", "control:3030") if err != nil { _, _ = exeCmd("sleep 5") errorHandler(w, r, err, http.StatusInternalServerError) return "" } defer func() { _ = conn.Close() }() _, _ = fmt.Fprintf(conn, "log-%s\n", logType) reader := bufio.NewReader(conn) contents, err := io.ReadAll(reader) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return "" } return string(contents) } func wsErrorHandler(err error) { log.Printf("wsErrorHandler: %v", err) pc := make([]uintptr, 15) n := runtime.Callers(2, pc) frames := runtime.CallersFrames(pc[:n]) frame, _ := frames.Next() fmt.Printf("%s:%d, %s\n", frame.File, frame.Line, frame.Function) debug.PrintStack() } func showLog(ws *websocket.Conn, logType string) { conn, err := net.Dial("tcp", "control:3030") if err != nil { _, _ = exeCmd("sleep 5") wsErrorHandler(err) return } defer func() { _ = conn.Close() }() _, _ = fmt.Fprintf(conn, "log-%s\n", logType) scanner := bufio.NewScanner(conn) for scanner.Scan() { msg := scanner.Text() if logType != "audit" || strings.Contains(msg, "[AUDIT]") { _ = ws.SetWriteDeadline(time.Now().Add(writeWait)) if err := ws.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil { // Probably "websocket: close sent" return } } } if err := scanner.Err(); err != nil { wsErrorHandler(err) return } } func reader(ws *websocket.Conn) { defer func() { _ = ws.Close() }() ws.SetReadLimit(512) _ = ws.SetReadDeadline(time.Now().Add(pongWait)) ws.SetPongHandler(func(string) error { _ = ws.SetReadDeadline(time.Now().Add(pongWait)); return nil }) for { _, _, err := ws.ReadMessage() if err != nil { break } } } func writer(ws *websocket.Conn, logType string) { pingTicker := time.NewTicker(pingPeriod) defer func() { pingTicker.Stop() _ = ws.Close() }() go showLog(ws, logType) for range pingTicker.C { _ = ws.SetWriteDeadline(time.Now().Add(writeWait)) if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil { // Probably "websocket: close sent" return } } } func wsHandler(w http.ResponseWriter, r *http.Request) { ws, err := upgrader.Upgrade(w, r, nil) if err != nil { if _, ok := err.(websocket.HandshakeError); !ok { log.Println(err) } return } logType := r.FormValue("logType") switch logType { case "boulder": case "audit": case "labca": case "cron": case "web": default: errorHandler(w, r, fmt.Errorf("unknown log type '%s'", logType), http.StatusBadRequest) return } go writer(ws, logType) reader(ws) } func _buildCI(r *http.Request, session *sessions.Session, isRoot bool) *CertificateInfo { ci := &CertificateInfo{ IsRoot: isRoot, CreateType: "generate", CommonName: "Root CA", RequestBase: r.Header.Get("X-Request-Base"), NumDays: 3652, // 10 years } if !isRoot { ci.CommonName = "CA" ci.NumDays = 1826 // 5 years } ci.Initialize() if session.Values["ct"] != nil { if !isRoot && session.Values["ct"].(string) == "generate" { ci.IsRootGenerated = true } ci.CreateType = session.Values["ct"].(string) } if session.Values["kt"] != nil { ci.KeyType = session.Values["kt"].(string) } if session.Values["c"] != nil { ci.Country = session.Values["c"].(string) } if session.Values["o"] != nil { ci.Organization = session.Values["o"].(string) } if session.Values["cn"] != nil { ci.CommonName = session.Values["cn"].(string) ci.CommonName = strings.ReplaceAll(ci.CommonName, "Root", "") ci.CommonName = strings.ReplaceAll(ci.CommonName, " ", " ") } return ci } func issuerNameID(certfile string) (int64, error) { cf, err := os.ReadFile(certfile) if err != nil { log.Printf("issuerNameID: could not read cert file: %v", err) return 0, err } cpb, _ := pem.Decode(cf) crt, err := x509.ParseCertificate(cpb.Bytes) if err != nil { log.Printf("issuerNameID: could not parse x509 file: %v", err) return 0, err } // From issuance/issuance.go : func truncatedHash h := crypto.SHA1.New() h.Write(crt.RawSubject) s := h.Sum(nil) return int64(big.NewInt(0).SetBytes(s[:7]).Int64()), nil } func _certCreate(w http.ResponseWriter, r *http.Request, certBase string, isRoot bool) bool { if r.Method == "POST" { if err := r.ParseMultipartForm(2 * 1024 * 1024); err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return false } if r.Form.Get("revertroot") != "" { // From issuer certificate creation page it is possible to remove the root again and start over rootseqnr := "01" seqnr := "01" err := deleteFiles(fmt.Sprintf("%sroot-%s*", CERT_FILES_PATH, rootseqnr)) if err != nil { fmt.Printf("failed to delete root %s files: %+v\n", rootseqnr, err.Error()) } err = deleteFiles(fmt.Sprintf("%sissuer-%s*", CERT_FILES_PATH, seqnr)) if err != nil { fmt.Printf("failed to delete issuer %s files: %+v\n", seqnr, err.Error()) } cfg := &HSMConfig{} cfg.Initialize("issuer", seqnr) _ = cfg.ClearAll() cfg.Initialize("root", rootseqnr) _ = cfg.ClearAll() certBase = "root-01" isRoot = true r.Method = "GET" sess, _ := sessionStore.Get(r, "labca") sess.Values["ct"] = "generate" if err := sess.Save(r, w); err != nil { log.Printf("cannot save session: %s\n", err) } } else if r.Form.Get("ack-rootkey") == "yes" { // Root Key was shown, do we need to keep it online? viper.Set("keep_root_offline", r.Form.Get("keep-root-online") != "true") _ = viper.WriteConfig() // Undo what setupHandler did when showing the public key... _, errPem := os.Stat("data/root-ca.pem") _, errTmp := os.Stat("data/root-ca.pem_TMP") if errors.Is(errPem, fs.ErrNotExist) && !errors.Is(errTmp, fs.ErrNotExist) { _, _ = exeCmd("mv data/root-ca.pem_TMP data/root-ca.pem") } r.Method = "GET" return true } } if _, err := os.Stat(CERT_FILES_PATH + certBase + "-cert.pem"); errors.Is(err, fs.ErrNotExist) { session, _ := sessionStore.Get(r, "labca") switch r.Method { case "GET": ci := _buildCI(r, session, isRoot) if isRoot && (certBase == "root-ca" || certBase == "test-root" || certBase == "root-01") { ci.IsFirst = true } else if !isRoot && (certBase == "ca-int" || certBase == "test-ca" || certBase == "issuer-01") { ci.IsFirst = true } if len(r.URL.Query()["root"]) > 0 { certFile := locateFile(r.URL.Query()["root"][0] + ".pem") ci.RootEnddate, err = getCertFileNotAFter(certFile) if err != nil { fmt.Println(err.Error()) errorHandler(w, r, err, http.StatusInternalServerError) return false } ci.RootSubject, err = getCertFileSubject(certFile) if err != nil { fmt.Println(err.Error()) errorHandler(w, r, err, http.StatusInternalServerError) return false } subjectMap := parseSubjectDn(ci.RootSubject) if val, ok := subjectMap["C"]; ok { ci.Country = val } if val, ok := subjectMap["O"]; ok { ci.Organization = val } } else if !isRoot { certFile := CERT_FILES_PATH + "root-01-cert.pem" // The rules are quite strict on what type is allowed for issuer certs! crt, err := readCertificate(certFile) if err == nil { validKeyTypes := make(map[string]string) if crt.PublicKeyAlgorithm == x509.RSA { for k, v := range ci.KeyTypes { if strings.HasPrefix(k, "rsa") { validKeyTypes[k] = v } } } if crt.PublicKeyAlgorithm == x509.ECDSA { if crt.SignatureAlgorithm == x509.ECDSAWithSHA256 { validKeyTypes["ecdsa256"] = "ECDSA-256" } if crt.SignatureAlgorithm == x509.ECDSAWithSHA384 { validKeyTypes["ecdsa384"] = "ECDSA-384" } } ci.KeyTypes = validKeyTypes } ci.RootEnddate, err = getCertFileNotAFter(certFile) if err != nil { fmt.Println(err.Error()) errorHandler(w, r, err, http.StatusInternalServerError) return false } ci.RootSubject, err = getCertFileSubject(certFile) if err != nil { fmt.Println(err.Error()) errorHandler(w, r, err, http.StatusInternalServerError) return false } } render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false case "POST": if err := r.ParseMultipartForm(2 * 1024 * 1024); err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return false } ci := &CertificateInfo{ IsRoot: r.Form.Get("cert") == "root", } ci.Initialize() ci.IsRoot = r.Form.Get("cert") == "root" ci.CreateType = r.Form.Get("createtype") if r.Form.Get("keytype") != "" { ci.KeyType = r.Form.Get("keytype") } ci.Country = r.Form.Get("c") ci.Organization = r.Form.Get("o") ci.CommonName = r.Form.Get("cn") ci.RootEnddate = r.Form.Get("root-enddate") ci.RootSubject = r.Form.Get("root-subject") if r.Form.Get("numdays") != "" { ci.NumDays, err = strconv.Atoi(r.Form.Get("numdays")) if err != nil { if ci.IsRoot { ci.NumDays = 3652 } else { ci.NumDays = 1826 } } } if ci.CreateType == "import" { file, handler, err := r.FormFile("import") if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return false } defer func() { _ = file.Close() }() ci.ImportFile = file ci.ImportHandler = handler ci.ImportPwd = r.Form.Get("import-pwd") } ci.Key = r.Form.Get("key") ci.Passphrase = r.Form.Get("passphrase") ci.Certificate = r.Form.Get("certificate") ci.RequestBase = r.Header.Get("X-Request-Base") if !ci.Validate() { if session.Values["csr"] == true { delete(ci.Errors, "Key") } else { render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false } } wasCSR := session.Values["csr"] == true if r.Form.Get("ack-rootkey") != "yes" { if r.Form.Get("rootkey") != "" { rootci := &CertificateInfo{ IsRoot: true, Key: r.Form.Get("rootkey"), Passphrase: r.Form.Get("rootpassphrase"), } if !rootci.StoreRootKey("data/") { ci.Errors["Modal"] = rootci.Errors["Modal"] render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "GetRootKey": true, "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false } } if r.Form.Get("crl") != "" { rootci := &CertificateInfo{ IsRoot: true, CRL: r.Form.Get("crl"), } if !rootci.StoreCRL("data/") { ci.Errors["Modal"] = rootci.Errors["Modal"] csr, err := os.Open(CERT_FILES_PATH + certBase + ".csr") // TODO !! if err != nil { ci.Errors[cases.Title(language.Und).String(ci.CreateType)] = "Error reading .csr file! See LabCA logs for details" log.Printf("_certCreate: read csr: %v", err) render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false } defer func() { _ = csr.Close() }() b, _ := io.ReadAll(csr) render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "CSR": string(b), "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false } } if err := ci.Create(certBase, wasCSR); err != nil { if err.Error() == "NO_ROOT_KEY" { if r.Form.Get("generate") != "" { if r.Form.Get("rootkey") == "" { render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "GetRootKey": true, "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false } else { rootci := &CertificateInfo{ IsRoot: true, Key: r.Form.Get("rootkey"), Passphrase: r.Form.Get("rootpassphrase"), } if !rootci.StoreRootKey("data/") { ci.Errors["Modal"] = rootci.Errors["Modal"] render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "GetRootKey": true, "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false } render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false } } if r.Form.Get("getcsr") != "" { csr, err := os.Open(CERT_FILES_PATH + certBase + ".csr") // TODO ! if err != nil { ci.Errors[cases.Title(language.Und).String(ci.CreateType)] = "Error reading .csr file! See LabCA logs for details" log.Printf("_certCreate: read csr: %v", err) render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false } defer func() { _ = csr.Close() }() b, _ := io.ReadAll(csr) session.Values["csr"] = true if err = session.Save(r, w); err != nil { log.Printf("cannot save session: %s\n", err) } render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "CSR": string(b), "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false } } else { ci.Errors[cases.Title(language.Und).String(ci.CreateType)] = err.Error() log.Printf("_certCreate: create failed: %v", err) render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false } } } if !ci.IsRoot { nameID, err := issuerNameID(CERT_FILES_PATH + "issuer-01-cert.pem") if err == nil { viper.Set("issuer_name_id", nameID) _ = viper.WriteConfig() } else { log.Printf("_certCreate: could not calculate IssuerNameID: %v", err) } } if viper.Get("labca.organization") == nil { viper.Set("labca.organization", ci.Organization) _ = viper.WriteConfig() } session.Values["ct"] = ci.CreateType session.Values["kt"] = ci.KeyType session.Values["c"] = ci.Country session.Values["o"] = ci.Organization session.Values["cn"] = ci.CommonName if err = session.Save(r, w); err != nil { log.Printf("cannot save session: %s\n", err) } // Fake the method to GET as we need to continue in the setupHandler() function r.Method = "GET" default: http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusSeeOther) return false } } return true } func deleteFiles(pattern string) error { files, err := filepath.Glob(pattern) if err != nil { return fmt.Errorf("failed to find files: %w", err) } ok := true for _, file := range files { err := os.Remove(file) if err != nil { ok = false fmt.Printf("failed to remove %s: %v\n", file, err) } } if !ok { return fmt.Errorf("failed to remove at least one file, see logs for details") } return nil } func _hostCommand(w http.ResponseWriter, r *http.Request, command string, params ...string) bool { conn, err := net.Dial("tcp", "control:3030") if err != nil { _, _ = exeCmd("sleep 5") errorHandler(w, r, err, http.StatusInternalServerError) return false } defer func() { _ = conn.Close() }() _, _ = fmt.Fprint(conn, command+"\n") for _, param := range params { _, _ = fmt.Fprint(conn, param+"\n") } reader := bufio.NewReader(conn) message, err := io.ReadAll(reader) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return false } if strings.Compare(string(message), "ok\n") == 0 { return true } if len(message) >= 4 { tail := message[len(message)-4:] if strings.Compare(string(tail), "\nok\n") == 0 { msg := message[0 : len(message)-4] log.Printf("Message from server: '%s'", msg) return true } } log.Printf("ERROR: Message from server: '%s'", message) errorHandler(w, r, errors.New(string(message)), http.StatusInternalServerError) return false } func randToken() string { b := make([]byte, 8) _, _ = rand.Read(b) return fmt.Sprintf("%x", b) } func _applyConfig() error { _, err := exeCmd("./apply") if err != nil { fmt.Println("") } return err } func _progress(stage string) int { max := 20.0 / 100.0 curr := 1.0 if stage == "register" { return int(math.Round(curr / max)) } curr += 2.0 if stage == "setup" { return int(math.Round(curr / max)) } curr += 3.0 if stage == "root-01" { return int(math.Round(curr / max)) } curr += 4.0 if stage == "issuer-01" { return int(math.Round(curr / max)) } curr += 3.0 if stage == "polling" { return int(math.Round(curr / max)) } curr += 4.0 if stage == "wrapup" { return int(math.Round(curr / max)) } curr += 3.0 if stage == "final" { return int(math.Round(curr / max)) } if stage == "standalone" { return int(math.Round(0.6 * curr / max)) } return 0 } func _helptext(stage string) template.HTML { switch stage { case "register": return template.HTML(fmt.Sprint("

You need to create an admin account for\n", "managing this instance of LabCA. There can only be one admin account, but you can configure all\n", "its attributes once the initial setup has completed.

Instead, you can also\n", "restore from a backup file of a\n", "previous LabCA installation.

\n", "

If you have a backup file from a previous LabCA installation and want to\n", "restore this instance with the exact same configuration, use that backup file here.\n", "

Otherwise you should follow the standard setup.

")) case "setup": return template.HTML(fmt.Sprint("

The fully qualified domain name (FQDN) is what end users will use\n", "to connect to this server. It was provided in the initial setup and is shown here for reference.

\n", "

Please fill in a DNS server (and optionally port, default is ':53') that will be used to lookup\n", "the domains for which a certificate is requested.

\n", "

LabCA is primarily intended for use inside an organization where all domains end in the same\n", "domain, e.g. '.localdomain'. In lockdown mode only those domains are allowed. In whitelist mode\n", "those domains are allowed next to all official, internet accessible domains and in standard\n", "mode only the official domains are allowed.

")) case "root-01": return template.HTML(fmt.Sprint("

This is the top level certificate that will sign the issuer\n", "certificate(s). You can either generate a fresh Root CA (Certificate Authority) or import an\n", "existing one, e.g. a backup from another LabCA instance.

\n", "

If you want to generate a new certificate, pick a key type and strength (the higher the number the\n", "more secure, ECDSA is more modern than RSA), provide a country and organization name,\n", "and the common name. It is recommended that the common name contains the word 'Root' as well\n", "as your organization name so you can recognize it, and that's why that is automatically filled\n", "once you leave the organization field.

")) case "issuer-01": return template.HTML(fmt.Sprint("

This is what end users will see as the issuing certificate. Again,\n", "you can either generate a fresh certificate or import an existing one, as long as it is signed by\n", "the Root CA from the previous step.

\n", "

If you want to generate a certificate, by default the same key type and strength is selected as\n", "was chosen in the previous step when generating the root, but you may choose a different\n", "one (if technically possible). By default the common name is the same as the CN for the Root CA, minus\n", "the word 'Root'.

\n")) case "standalone": return template.HTML(fmt.Sprint("

Currently only step-ca is supported, using the MySQL database backend.\n", "Please provide the necessary connectiuon details here.")) default: return template.HTML("") } } func _setupAdminUser(w http.ResponseWriter, r *http.Request) bool { switch r.Method { case "GET": reg := &User{ RequestBase: r.Header.Get("X-Request-Base"), } render(w, r, "register:manage", map[string]interface{}{"User": reg, "IsLogin": true, "Progress": _progress("register"), "HelpText": _helptext("register")}) return false case "POST": isMultipart := true if err := r.ParseMultipartForm(1 * 1024 * 1024); err != nil { isMultipart = false if err != http.ErrNotMultipart { errorHandler(w, r, err, http.StatusInternalServerError) return false } else if err := r.ParseForm(); err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return false } } // Restore a backup file if isMultipart { reg := &User{ Errors: make(map[string]string), RequestBase: r.Header.Get("X-Request-Base"), } file, header, err := r.FormFile("file") if err != nil { fmt.Println(err) reg.Errors["File"] = "Could not read uploaded file" render(w, r, "register:manage", map[string]interface{}{"User": reg, "IsLogin": true, "Progress": _progress("register"), "HelpText": _helptext("register")}) return false } defer func() { _ = file.Close() }() out, err := os.Create("/opt/backup/" + header.Filename) if err != nil { fmt.Println(err) reg.Errors["File"] = "Could not create local file" render(w, r, "register:manage", map[string]interface{}{"User": reg, "IsLogin": true, "Progress": _progress("register"), "HelpText": _helptext("register")}) return false } defer func() { _ = out.Close() }() _, copyError := io.Copy(out, file) if copyError != nil { fmt.Println(err) reg.Errors["File"] = "Could not store uploaded file" render(w, r, "register:manage", map[string]interface{}{"User": reg, "IsLogin": true, "Progress": _progress("register"), "HelpText": _helptext("register")}) return false } // Cannot use _hostCommand() as we need different error handling conn, err := net.Dial("tcp", "control:3030") if err != nil { fmt.Println(err) reg.Errors["File"] = "Could not import backup file: error communicating with control" render(w, r, "register:manage", map[string]interface{}{"User": reg, "IsLogin": true, "Progress": _progress("register"), "HelpText": _helptext("register")}) return false } defer func() { _ = conn.Close() }() _, _ = fmt.Fprint(conn, "backup-restore\n"+header.Filename+"\n") reader := bufio.NewReader(conn) message, err := io.ReadAll(reader) if err != nil { fmt.Println(err) reg.Errors["File"] = "Could not import backup file: error reading control response" render(w, r, "register:manage", map[string]interface{}{"User": reg, "IsLogin": true, "Progress": _progress("register"), "HelpText": _helptext("register")}) return false } if strings.Compare(string(message), "ok\n") == 0 { if err := viper.ReadInConfig(); err != nil { fmt.Println(err) reg.Errors["File"] = "Could not read config after importing backup" render(w, r, "register:manage", map[string]interface{}{"User": reg, "IsLogin": true, "Progress": _progress("register"), "HelpText": _helptext("register")}) return false } viper.Set("config.complete", false) _ = viper.WriteConfig() err = _applyConfig() if err != nil { fmt.Println("Could not apply config, trying to migrate by restarting...") _hostCommand(w, r, "labca-restart") reg.Errors["File"] = "Could not apply config, trying to migrate by restarting..." render(w, r, "register:manage", map[string]interface{}{"User": reg, "IsLogin": true, "Progress": _progress("register"), "HelpText": _helptext("register")}) return false } defer _hostCommand(w, r, "docker-restart") http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/final", http.StatusFound) return true } if len(message) >= 4 { tail := message[len(message)-4:] if strings.Compare(string(tail), "\nok\n") == 0 { msg := message[0 : len(message)-4] log.Printf("Message from server: '%s'", msg) lines := strings.Split(strings.TrimSpace(string(msg)), "\n") reg.Errors["File"] = "Could not import backup file: " + lines[0] + "\nSee /opt/logs/commander.log in control container." render(w, r, "register:manage", map[string]interface{}{"User": reg, "IsLogin": true, "Progress": _progress("register"), "HelpText": _helptext("register")}) return false } } log.Printf("ERROR: Message from server: '%s'", message) lines := strings.Split(strings.TrimSpace(string(message)), "\n") reg.Errors["File"] = "Could not import backup file: " + lines[0] + "\nSee /opt/logs/commander.log in control container." render(w, r, "register:manage", map[string]interface{}{"User": reg, "IsLogin": true, "Progress": _progress("register"), "HelpText": _helptext("register")}) return false } // Regular setup form handling reg := &User{ Name: r.Form.Get("username"), Email: r.Form.Get("email"), Password: r.Form.Get("password"), Confirm: r.Form.Get("confirm"), RequestBase: r.Header.Get("X-Request-Base"), } if !reg.Validate(true, false) { render(w, r, "register:manage", map[string]interface{}{"User": reg, "IsLogin": true, "Progress": _progress("register"), "HelpText": _helptext("register")}) return false } hash, err := bcrypt.GenerateFromPassword([]byte(reg.Password), bcrypt.MinCost) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return false } viper.Set("user.name", reg.Name) viper.Set("user.email", reg.Email) viper.Set("user.password", string(hash)) _ = viper.WriteConfig() session, _ := sessionStore.Get(r, "labca") session.Values["user"] = reg.Name if err = session.Save(r, w); err != nil { log.Printf("cannot save session: %s\n", err) } // Fake the method to GET as we need to continue in the setupHandler() function r.Method = "GET" default: http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusSeeOther) return false } return true } func _setupBaseConfig(w http.ResponseWriter, r *http.Request) bool { switch r.Method { case "GET": domain := viper.GetString("labca.fqdn") pos := strings.Index(domain, ".") if pos > -1 { pos = pos + 1 domain = domain[pos:] } cfg := &SetupConfig{ Fqdn: viper.GetString("labca.fqdn"), DomainMode: "lockdown", LockdownDomains: domain, WhitelistDomains: domain, LDPublicContacts: true, RequestBase: r.Header.Get("X-Request-Base"), } render(w, r, "setup:manage", map[string]interface{}{"SetupConfig": cfg, "Progress": _progress("setup"), "HelpText": _helptext("setup")}) return false case "POST": if err := r.ParseForm(); err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return false } cfg := &SetupConfig{ Fqdn: r.Form.Get("fqdn"), DNS: r.Form.Get("dns"), DomainMode: r.Form.Get("domain_mode"), LockdownDomains: r.Form.Get("lockdown_domains"), WhitelistDomains: r.Form.Get("whitelist_domains"), LDPublicContacts: (r.Form.Get("ld_public_contacts") == "true"), RequestBase: r.Header.Get("X-Request-Base"), } if !cfg.Validate(false) { render(w, r, "setup:manage", map[string]interface{}{"SetupConfig": cfg, "Progress": _progress("setup"), "HelpText": _helptext("setup")}) return false } matched, err := regexp.MatchString(":\\d+$", cfg.DNS) if err == nil && !matched { cfg.DNS += ":53" } viper.Set("labca.fqdn", cfg.Fqdn) viper.Set("labca.dns", cfg.DNS) viper.Set("labca.domain_mode", cfg.DomainMode) if cfg.DomainMode == "lockdown" { viper.Set("labca.lockdown", cfg.LockdownDomains) viper.Set("labca.ld_public_contacts", cfg.LDPublicContacts) } if cfg.DomainMode == "whitelist" { viper.Set("labca.whitelist", cfg.WhitelistDomains) } _ = viper.WriteConfig() // Fake the method to GET as we need to continue in the setupHandler() function r.Method = "GET" default: http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusSeeOther) return false } return true } func writeStandaloneConfig(cfg *StandaloneConfig) { conn := cfg.MySQLUser if cfg.MySQLPasswd != "" { conn += ":" + cfg.MySQLPasswd } conn += "@" if strings.HasPrefix(cfg.MySQLServer, "tcp(") { conn += cfg.MySQLServer + ":" + cfg.MySQLPort } else { conn += "tcp(" + cfg.MySQLServer + ":" + cfg.MySQLPort + ")" } conn += "/" + cfg.MySQLDBName restart := viper.GetBool("server.https") != cfg.UseHTTPS || viper.GetString("server.cert") != cfg.CertPath || viper.GetString("server.key") != cfg.KeyPath dbConn = conn viper.Set("db.conn", conn) viper.Set("backend", cfg.Backend) viper.Set("server.https", cfg.UseHTTPS) if cfg.UseHTTPS { viper.Set("server.cert", cfg.CertPath) viper.Set("server.key", cfg.KeyPath) } viper.Set("config.complete", true) _ = viper.WriteConfig() if restart { if cfg.UseHTTPS { fmt.Println("### Please restart the application to use the HTTPS certificate!") } else { fmt.Println("### Please restart the application!") } } } func setupStandalone(w http.ResponseWriter, r *http.Request) { switch r.Method { case "GET": cfg := &StandaloneConfig{ RequestBase: r.Header.Get("X-Request-Base"), Backend: "step-ca", MySQLServer: "127.0.0.1", MySQLPort: "3306", MySQLDBName: "stepca", UseHTTPS: false, CertPath: configPath + string(os.PathSeparator) + "labca.crt", KeyPath: configPath + string(os.PathSeparator) + "labca.key", } render(w, r, "standalone:manage", map[string]interface{}{"SetupConfig": cfg, "Progress": _progress("standalone"), "HelpText": _helptext("standalone")}) return case "POST": if err := r.ParseForm(); err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return } cfg := &StandaloneConfig{ Backend: r.Form.Get("backend"), MySQLServer: r.Form.Get("mysql_server"), MySQLPort: r.Form.Get("mysql_port"), MySQLDBName: r.Form.Get("mysql_dbname"), MySQLUser: r.Form.Get("mysql_user"), MySQLPasswd: r.Form.Get("mysql_passwd"), UseHTTPS: (r.Form.Get("use_https") == "https"), CertPath: r.Form.Get("cert_path"), KeyPath: r.Form.Get("key_path"), RequestBase: r.Header.Get("X-Request-Base"), } if !cfg.Validate() { render(w, r, "standalone:manage", map[string]interface{}{"SetupConfig": cfg, "Progress": _progress("standalone"), "HelpText": _helptext("standalone")}) return } writeStandaloneConfig(cfg) // Fake the method to GET as we need to continue in the setupHandler() function r.Method = "GET" default: http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusSeeOther) return } http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/", http.StatusFound) } func setupHandler(w http.ResponseWriter, r *http.Request) { if viper.GetBool("config.complete") { render(w, r, "index:manage", map[string]interface{}{"Message": template.HTML("Setup already completed! Go home")}) return } // 1. Setup admin user if viper.Get("user.password") == nil { if !_setupAdminUser(w, r) { return } } // 1a. Go to standalone setup if viper.GetBool("standalone") { setupStandalone(w, r) return } // 2. Setup essential configuration if viper.Get("labca.dns") == nil { if !_setupBaseConfig(w, r) { return } } // 3. Setup root CA certificate if !_certCreate(w, r, "root-01", true) { // Cleanup the cert (if it even exists) so we will retry on the next run if _, err := os.Stat(CERT_FILES_PATH + "root-01-cert.pem"); !errors.Is(err, fs.ErrNotExist) { _, _ = exeCmd("mv " + CERT_FILES_PATH + "root-01-cert.pem " + CERT_FILES_PATH + "root-01-cert.pem_TMP") } return } // 4. Setup issuer certificate if !_certCreate(w, r, "issuer-01", false) { // Cleanup the cert (if it even exists) so we will retry on the next run _ = os.Remove(CERT_FILES_PATH + "issuer-01-cert.pem") return } // 5. Apply configuration / populate with certificate info err := _applyConfig() if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return } if !viper.GetBool("config.restarted") { // Don't let the retry mechanism generate new restartSecret! if r.Header.Get("X-Requested-With") == "XMLHttpRequest" { _, _ = exeCmd("sleep 5") render(w, r, "index", map[string]interface{}{"Message": "Retry OK"}) } else { // 8. Restart application restartSecret = randToken() http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/wait?restart="+restartSecret, http.StatusFound) } return } render(w, r, "wrapup:manage", map[string]interface{}{"Progress": _progress("wrapup"), "HelpText": _helptext("wrapup")}) } func waitHandler(w http.ResponseWriter, r *http.Request) { if viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/", http.StatusFound) return } render(w, r, "polling:manage", map[string]interface{}{"Progress": _progress("polling"), "HelpText": _helptext("polling")}) } func restartHandler(w http.ResponseWriter, r *http.Request) { if viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/", http.StatusFound) return } if strings.Compare(r.URL.Query().Get("token"), restartSecret) != 0 { log.Println("WARNING: Restart token ('" + r.URL.Query().Get("token") + "') does not match our secret ('" + restartSecret + "')!") http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } viper.Set("config.restarted", true) _ = viper.WriteConfig() if !_hostCommand(w, r, "docker-restart") { viper.Set("config.restarted", false) _ = viper.WriteConfig() return } } func finalHandler(w http.ResponseWriter, r *http.Request) { if viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/", http.StatusFound) return } t := viper.GetTime("config.cert_requested") if !t.IsZero() && t.After(time.Now().Add(-5*time.Minute)) { // Too soon if r.Header.Get("X-Requested-With") == "XMLHttpRequest" { w.Header().Set("Content-Type", "application/json") if viper.GetBool("config.error") { viper.Set("config.cert_requested", nil) _ = viper.WriteConfig() } _ = json.NewEncoder(w).Encode(map[string]interface{}{"complete": viper.GetBool("config.complete"), "error": viper.GetBool("config.error")}) } else { render(w, r, "polling:manage", map[string]interface{}{"Progress": _progress("polling"), "HelpText": _helptext("polling")}) } return } viper.Set("config.cert_requested", time.Now()) if viper.GetBool("config.error") { viper.Set("config.error", false) } _ = viper.WriteConfig() // 9. Setup our own web certificate if !_hostCommand(w, r, "acme-request") { viper.Set("config.error", true) _ = viper.WriteConfig() http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/logs/cert", http.StatusSeeOther) return } // 10. remove the temporary bit from nginx config if !_hostCommand(w, r, "nginx-remove-redirect") { return } // 11. reload nginx if !_hostCommand(w, r, "nginx-reload") { return } viper.Set("config.complete", true) _ = viper.WriteConfig() if r.Header.Get("X-Requested-With") == "XMLHttpRequest" { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{"complete": viper.GetBool("config.complete")}) } else { render(w, r, "final:manage", map[string]interface{}{"RequestBase": r.Header.Get("X-Request-Base"), "Progress": _progress("final"), "HelpText": _helptext("final")}) } } func showErrorHandler(w http.ResponseWriter, r *http.Request) { errorHandler(w, r, nil, http.StatusInternalServerError) } // RangeStructer takes the first argument, which must be a struct, and // returns the value of each field in a slice. It will return nil // if there are no arguments or first argument is not a struct func RangeStructer(args ...interface{}) []interface{} { if len(args) == 0 { return nil } v := reflect.ValueOf(args[0]) if v.Kind() != reflect.Struct { return nil } out := make([]interface{}, v.NumField()) for i := 0; i < v.NumField(); i++ { switch v.Field(i).Kind() { case reflect.String: if v.Field(i).Type().String() == "template.HTML" { out[i] = template.HTML(v.Field(i).String()) } else { out[i] = v.Field(i).String() } case reflect.Bool: out[i] = v.Field(i).Bool() default: out[i] = v.Field(i) } } return out } func accountsHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } Accounts, err := GetAccounts(w, r) if err == nil { render(w, r, "list:accounts", map[string]interface{}{"List": Accounts, "Title": "ACME"}) } } func accountHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } vars := mux.Vars(r) id := vars["id"] AccountDetails, err := GetAccount(w, r, id) if err == nil { render(w, r, "show:accounts", map[string]interface{}{"Details": AccountDetails, "Title": "ACME"}) } } func ordersHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } Orders, err := GetOrders(w, r, "") if err == nil { render(w, r, "list:orders", map[string]interface{}{"List": Orders, "Title": "ACME"}) } } func orderHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } vars := mux.Vars(r) id := vars["id"] OrderDetails, err := GetOrder(w, r, id) if err == nil { render(w, r, "show:orders", map[string]interface{}{"Details": OrderDetails, "Title": "ACME"}) } } func authzHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } Authz, err := GetAuthzs(w, r, "", []string{}) if err == nil { render(w, r, "list:authz", map[string]interface{}{"List": Authz, "Title": "ACME"}) } } func authHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } vars := mux.Vars(r) id := vars["id"] AuthDetails, err := GetAuthz(w, r, id) if err == nil { render(w, r, "show:authz", map[string]interface{}{"Details": AuthDetails, "Title": "ACME"}) } } func challengesHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } Challenges, err := GetChallenges(w, r, "", []string{}) if err == nil { render(w, r, "list:challenges", map[string]interface{}{"List": Challenges, "Title": "ACME"}) } } func challengeHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } vars := mux.Vars(r) id := vars["id"] ChallengeDetails, err := GetChallenge(w, r, id) if err == nil { render(w, r, "show:challenges", map[string]interface{}{"Details": ChallengeDetails, "Title": "ACME"}) } } func certificatesHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } Certificates, err := GetCertificates(w, r, "") if err == nil { render(w, r, "list:certificates", map[string]interface{}{"List": Certificates, "Title": "ACME"}) } } func certificateHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) return } var serial string vars := mux.Vars(r) id := vars["id"] if viper.GetString("backend") != "step-ca" { _, err := strconv.Atoi(vars["id"]) if err != nil { serial = vars["id"] } } CertificateDetails, err := GetCertificate(w, r, id, serial) if err == nil { render(w, r, "show:certificates", map[string]interface{}{"Details": CertificateDetails, "Title": "ACME"}) } } func certRevokeHandler(w http.ResponseWriter, r *http.Request) { if !viper.GetBool("config.complete") { errorHandler(w, r, errors.New("method not allowed at this point"), http.StatusMethodNotAllowed) return } if r.Method == "POST" { if err := r.ParseForm(); err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return } serial := r.Form.Get("serial") reason := r.Form.Get("reason") if !_hostCommand(w, r, "revoke-cert", serial, reason) { return } } } func statsHandler(w http.ResponseWriter, r *http.Request) { res := parseDockerStats(getLog(w, r, "stats")) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(res) } type navItem struct { Name string Icon string Attrs map[template.HTMLAttr]string IsActive bool SubMenu []navItem } func _matchPrefix(uri string, prefix string) bool { return (uri == prefix || strings.HasPrefix(uri, prefix+"/")) } func _acmeNav(active string, uri string, requestBase string) navItem { isAcmeActive := _matchPrefix(uri, "/accounts") || _matchPrefix(uri, "/orders") || _matchPrefix(uri, "/authz") || _matchPrefix(uri, "/challenges") || _matchPrefix(uri, "/certificates") || false accounts := navItem{ Name: "Accounts", Icon: "fa-list-alt", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/accounts", "title": "ACME Accounts", }, } orders := navItem{ Name: "Orders", Icon: "fa-tags", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/orders", "title": "ACME Orders", }, } authz := navItem{ Name: "Authorizations", Icon: "fa-chain", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/authz", "title": "ACME Authorizations", }, } challenges := navItem{ Name: "Challenges", Icon: "fa-exchange", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/challenges", "title": "ACME Challenges", }, } certificates := navItem{ Name: "Certificates", Icon: "fa-lock", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/certificates", "title": "ACME Certificates", }, } acme := navItem{ Name: "ACME", Icon: "fa-sitemap", Attrs: map[template.HTMLAttr]string{ "href": "#", "title": "Automated Certificate Management Environment", }, IsActive: isAcmeActive, SubMenu: []navItem{accounts, certificates, orders, authz, challenges}, } // set active menu class switch active { case "accounts": accounts.Attrs["class"] = "active" case "certificates": certificates.Attrs["class"] = "active" case "orders": orders.Attrs["class"] = "active" case "authz": authz.Attrs["class"] = "active" case "challenges": challenges.Attrs["class"] = "active" } return acme } func activeNav(active string, uri string, requestBase string) []navItem { // create menu items home := navItem{ Name: "Dashboard", Icon: "fa-dashboard", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/", "title": "Main page with the status of the system", }, } acme := _acmeNav(active, uri, requestBase) cert := navItem{ Name: "Web Certificate", Icon: "fa-lock", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/logs/cert", "title": "Log file for the certificate renewal for this server", }, } boulder := navItem{ Name: "ACME", Icon: "fa-search-plus", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/logs/boulder", "title": "Live view on the backend ACME application logs", }, } audit := navItem{ Name: "ACME Audit Log", Icon: "fa-paw", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/logs/audit", "title": "Live view on only the audit messages in the backend ACME application logs", }, } labca := navItem{ Name: "LabCA", Icon: "fa-edit", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/logs/labca", "title": "Live view on the logs for this LabCA web application", }, } cron := navItem{ Name: "Cron Log", Icon: "fa-clock-o", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/logs/cron", "title": "Live view on the logs for the cron jobs for LabCA", }, } web := navItem{ Name: "Web Server", Icon: "fa-globe", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/logs/web", "title": "Live view on the NGINX web server access log", }, } logs := navItem{ Name: "Logs", Icon: "fa-files-o", Attrs: map[template.HTMLAttr]string{ "href": "#", "title": "Log Files", }, IsActive: strings.HasPrefix(uri, "/logs/"), SubMenu: []navItem{boulder, audit, cron, labca, cert, web}, } manage := navItem{ Name: "Manage", Icon: "fa-wrench", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/manage", "title": "Manage the system", }, } about := navItem{ Name: "About", Icon: "fa-comments", Attrs: map[template.HTMLAttr]string{ "href": requestBase + "/about", "title": "About LabCA", }, } public := navItem{ Name: "Public Area", Icon: "fa-home", Attrs: map[template.HTMLAttr]string{ "href": "http://" + viper.GetString("labca.fqdn"), "title": "The non-Admin pages of this LabCA instance", }, } // set active menu class switch active { case "about": about.Attrs["class"] = "active" case "index": home.Attrs["class"] = "active" case "manage": manage.Attrs["class"] = "active" } if viper.GetBool("standalone") { return []navItem{home, acme, manage, about} } return []navItem{home, acme, logs, manage, about, public} } func render(w http.ResponseWriter, r *http.Request, view string, data map[string]interface{}) { viewSlice := strings.Split(view, ":") menu := viewSlice[0] if len(viewSlice) > 1 { menu = viewSlice[1] } data["Menu"] = activeNav(menu, r.RequestURI, r.Header.Get("X-Request-Base")) if version != "" { data["Version"] = version } if webTitle != "" { data["WebTitle"] = webTitle } b, err := tmpls.Render("base.tmpl", "views/"+viewSlice[0]+".tmpl", data) if err != nil { errorHandler(w, r, err, http.StatusInternalServerError) return } _, _ = w.Write(b) } func notFoundHandler(w http.ResponseWriter, r *http.Request) { errorHandler(w, r, fmt.Errorf("NotFoundHandler for: %s %s", r.Method, r.URL), http.StatusNotFound) } func authorized(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Println(r.Method + " " + r.RequestURI) if r.RequestURI == "/login" || strings.Contains(r.RequestURI, "/static/") { next.ServeHTTP(w, r) } else { session, _ := sessionStore.Get(r, "labca") if session.Values["user"] != nil || (r.RequestURI == "/setup" && viper.Get("user.password") == nil) { // Keep setting the cookie so the expiration / max-age keeps renewing if err := session.Save(r, w); err != nil { log.Printf("cannot save session: %s\n", err) } next.ServeHTTP(w, r) } else { session.Values["bounce"] = r.RequestURI if err := session.Save(r, w); err != nil { log.Printf("cannot save session: %s\n", err) } http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/login", http.StatusFound) } } }) } func init() { if os.Getenv("DEVELOPMENT") != "" { isDev = true } address := flag.String("address", "", "Address to listen on (default 0.0.0.0 when using init)") configFile := flag.String("config", "", "File path to the configuration file for this application") init := flag.Bool("init", false, "Initialize the application for running standalone, create/update the config file") port := flag.Int("port", 0, "Port to listen on (default 3000 when using init)") versionFlag := flag.Bool("version", false, "Show version number and exit") decrypt := flag.String("d", "", "Decrypt a value") renewcrl := flag.Int("renewcrl", 0, "Check root CRL files and renew if nextUpdate is in less than this number of days") flag.Parse() if *versionFlag && standaloneVersion != "" { fmt.Println(standaloneVersion) os.Exit(0) } if *configFile == "" { viper.SetConfigName("config") ex, _ := os.Executable() exePath := filepath.Dir(ex) path, _ := filepath.Abs(exePath + "/..") configPath = path + "/data" viper.AddConfigPath(configPath) } else { _, err := os.Stat(*configFile) if errors.Is(err, fs.ErrNotExist) { _ = viper.WriteConfigAs(*configFile) } viper.AddConfigPath(filepath.Dir(*configFile)) configPath = filepath.Dir(*configFile) viper.SetConfigName(strings.TrimSuffix(filepath.Base(*configFile), filepath.Ext(*configFile))) } viper.SetDefault("config.complete", false) if err := viper.ReadInConfig(); err != nil { panic(fmt.Errorf("fatal error config file: '%s'", err)) } if *versionFlag && standaloneVersion == "" { fmt.Println(viper.GetString("version")) os.Exit(0) } if *decrypt != "" { plain, err := _decrypt(*decrypt) if err == nil { fmt.Println(string(plain)) os.Exit(0) } else { os.Exit(1) } } if *renewcrl != 0 { crlFiles, err := filepath.Glob(filepath.Join(CERT_FILES_PATH, "root-*-crl.pem")) if err != nil { fmt.Println(err) os.Exit(1) } for _, crlFile := range crlFiles { read, err := os.ReadFile(crlFile) if err != nil { fmt.Printf("could not read '%s': %s\n", crlFile, err.Error()) os.Exit(1) } block, _ := pem.Decode(read) if block == nil || block.Type != "X509 CRL" { fmt.Println(block) fmt.Println("failed to decode PEM block containing revocation list") os.Exit(1) } crl, err := x509.ParseRevocationList(block.Bytes) if err != nil { fmt.Printf("could not parse revocation list: %s\n", err.Error()) os.Exit(1) } now := time.Now() if crl.NextUpdate.Sub(now) < time.Hour*24*time.Duration(*renewcrl) { fmt.Printf("renewing crl file '%s'...\n", crlFile) re := regexp.MustCompile(`-(\d{2})-`) match := re.FindStringSubmatch(crlFile) if len(match) > 1 { seqnr := match[1] ci := &CertificateInfo{} ci.Initialize() err = ci.CeremonyRootCRL(seqnr) if err == nil { fmt.Printf("updated %s\n", crlFile) } else { fmt.Printf("could not update crl file '%s': %s\n", crlFile, err.Error()) os.Exit(1) } } else { fmt.Printf("could not extract sequence number from filename '%s'\n", crlFile) os.Exit(1) } } } os.Exit(0) } var err error if *init || viper.GetBool("standalone") { tmpls, err = templates.New().ParseEmbed(embeddedTemplates, "templates/") } else { tmpls, err = templates.New().ParseDir("./templates", "templates/") } if err != nil { panic(fmt.Errorf("fatal error templates: '%s'", err)) } if viper.Get("keys.auth") == nil { key := securecookie.GenerateRandomKey(32) if key == nil { panic(fmt.Errorf("fatal error random key")) } viper.Set("keys.auth", base64.StdEncoding.EncodeToString(key)) _ = viper.WriteConfig() } if viper.Get("keys.enc") == nil { key := securecookie.GenerateRandomKey(32) if key == nil { panic(fmt.Errorf("fatal error random key")) } viper.Set("keys.enc", base64.StdEncoding.EncodeToString(key)) _ = viper.WriteConfig() } if *init { if *address != "" { viper.Set("server.addr", *address) } if *port != 0 { viper.Set("server.port", *port) } viper.Set("standalone", true) _ = viper.WriteConfig() } if viper.Get("server.addr") == nil { viper.Set("server.addr", "0.0.0.0") _ = viper.WriteConfig() } if viper.Get("server.port") == nil { viper.Set("server.port", 3000) _ = viper.WriteConfig() } if viper.Get("server.session.maxage") == nil { viper.Set("server.session.maxage", 3600) // 1 hour _ = viper.WriteConfig() } if viper.Get("db.conn") == nil { viper.Set("db.type", "mysql") viper.Set("db.conn", "root@tcp(boulder-mysql:3306)/boulder_sa_integration") _ = viper.WriteConfig() } dbConn = viper.GetString("db.conn") dbType = viper.GetString("db.type") if viper.GetBool("standalone") { version = standaloneVersion } else { version = viper.GetString("version") if version == "" { version = standaloneVersion } } webTitle = viper.GetString("labca.web_title") if webTitle == "" { webTitle = "LabCA" } a := viper.GetString("server.addr") p := viper.GetInt("server.port") if *address != "" && *address != viper.GetString("server.addr") { a = *address } if *port != 0 && *port != viper.GetInt("server.port") { p = *port } listenAddress = fmt.Sprintf("%s:%d", a, p) updateAvailable = false if !viper.GetBool("standalone") { CheckUpgrades() } /* // TODO: Still needs to be done for this! // Store boulder chains if we don't have them already doWrite := false if viper.GetString("certs.ca") == "" { caChains := getRawCAChains() viper.Set("certs.ca", caChains) doWrite = true } if viper.GetString("certs.wfe") == "" { chains := getRawWFEChains() viper.Set("certs.wfe", chains) doWrite = true } if doWrite { viper.WriteConfig() } // TODO: also apply from here if different?? How exaclty is a code upgrade delaing with this?? */ } type BackupResult struct { Existed bool NewName string OrigName string } func (br BackupResult) Remove() { _ = os.Remove(br.NewName) } func (br BackupResult) Restore() { if br.Existed { _ = os.Rename(br.NewName, br.OrigName) } } func renameBackup(filename string) BackupResult { result := BackupResult{ Existed: false, } if _, err := os.Stat(filename); !errors.Is(err, os.ErrNotExist) { _ = os.Remove(filename + "_BAK") // May not exist... result.Existed = true } if !result.Existed { return result } err := os.Rename(filename, filename+"_BAK") if err != nil { fmt.Printf("warning: failed to backup previous file '%s': %s\n", filename, err.Error()) } else { result.OrigName = filename result.NewName = filename + "_BAK" } return result } func main() { tmpls.Parse() keys_auth, err := base64.StdEncoding.DecodeString(viper.GetString("keys.auth")) if err != nil { log.Fatalf("cannot decode configured 'keys.auth': %s\n", err) } keys_enc, err := base64.StdEncoding.DecodeString(viper.GetString("keys.enc")) if err != nil { log.Fatalf("cannot decode configured 'keys.enc': %s\n", err) } sessionStore = sessions.NewCookieStore(keys_auth, keys_enc) sessionStore.Options = &sessions.Options{ Path: "/", MaxAge: viper.GetInt("server.session.maxage") * 1, HttpOnly: true, Secure: viper.GetBool("server.https"), } r := mux.NewRouter() r.HandleFunc("/", rootHandler).Methods("GET") r.HandleFunc("/stats", statsHandler).Methods("GET") r.HandleFunc("/about", aboutHandler).Methods("GET") r.HandleFunc("/manage", manageHandler).Methods("GET", "POST") // r.HandleFunc("/manage/newissuer", manageNewIssuerHandler).Methods("GET", "POST") // r.HandleFunc("/manage/newroot", manageNewRootHandler).Methods("GET", "POST") r.HandleFunc("/final", finalHandler).Methods("GET") r.HandleFunc("/error", showErrorHandler).Methods("GET") r.HandleFunc("/login", loginHandler).Methods("GET", "POST") r.HandleFunc("/logout", logoutHandler).Methods("GET") r.HandleFunc("/logs/{type}", logsHandler).Methods("GET") r.HandleFunc("/restart", restartHandler).Methods("GET") r.HandleFunc("/setup", setupHandler).Methods("GET", "POST") r.HandleFunc("/wait", waitHandler).Methods("GET") r.HandleFunc("/ws", wsHandler).Methods("GET") r.HandleFunc("/accounts", accountsHandler).Methods("GET") r.HandleFunc("/accounts/{id}", accountHandler).Methods("GET") r.HandleFunc("/orders", ordersHandler).Methods("GET") r.HandleFunc("/orders/{id}", orderHandler).Methods("GET") r.HandleFunc("/authz", authzHandler).Methods("GET") r.HandleFunc("/authz/{id}", authHandler).Methods("GET") r.HandleFunc("/challenges", challengesHandler).Methods("GET") r.HandleFunc("/challenges/{id}", challengeHandler).Methods("GET") r.HandleFunc("/certificates", certificatesHandler).Methods("GET") r.HandleFunc("/certificates/{id}", certificateHandler).Methods("GET") r.HandleFunc("/certificates/{id}", certRevokeHandler).Methods("POST") r.PathPrefix("/backup/").Handler(http.StripPrefix("/backup/", http.FileServer(http.Dir("/opt/backup")))) r.NotFoundHandler = http.HandlerFunc(notFoundHandler) if viper.GetBool("standalone") || isDev { var sfs http.Handler if viper.GetBool("standalone") { sfs = http.FileServer(http.FS(staticFiles)) r.PathPrefix("/accounts/static/").Handler(http.StripPrefix("/accounts", sfs)) r.PathPrefix("/authz/static/").Handler(http.StripPrefix("/authz", sfs)) r.PathPrefix("/challenges/static/").Handler(http.StripPrefix("/challenges", sfs)) r.PathPrefix("/certificates/static/").Handler(http.StripPrefix("/certificates", sfs)) r.PathPrefix("/orders/static/").Handler(http.StripPrefix("/orders", sfs)) r.PathPrefix("/static/").Handler(sfs) } if isDev { sfs = http.FileServer(http.Dir("static")) r.PathPrefix("/accounts/static/").Handler(http.StripPrefix("/accounts/static/", sfs)) r.PathPrefix("/authz/static/").Handler(http.StripPrefix("/authz/static/", sfs)) r.PathPrefix("/challenges/static/").Handler(http.StripPrefix("/challenges/static/", sfs)) r.PathPrefix("/certs/static/").Handler(http.StripPrefix("/certs/static/", sfs)) r.PathPrefix("/certificates/static/").Handler(http.StripPrefix("/certificates/static/", sfs)) r.PathPrefix("/orders/static/").Handler(http.StripPrefix("/orders/static/", sfs)) r.PathPrefix("/logs/static/").Handler(http.StripPrefix("/logs/static/", sfs)) r.PathPrefix("/manage/static/").Handler(http.StripPrefix("/manage/static/", sfs)) r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", sfs)) } } r.Use(authorized) log.Printf("Listening on %s...\n", listenAddress) srv = &http.Server{ Handler: r, Addr: listenAddress, WriteTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second, } if viper.GetBool("server.https") { log.Fatal(srv.ListenAndServeTLS(viper.GetString("server.cert"), viper.GetString("server.key"))) } else { log.Fatal(srv.ListenAndServe()) } }