Files
labca/gui/main.go

3507 lines
100 KiB
Go

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("<p class=\"form-register\">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.<br><br><b>Instead, you can also\n",
"<a href=\"#\" onclick=\"false\" class=\"toggle-restore\">restore from a backup file</a> of a\n",
"previous LabCA installation.</b></p>\n",
"<p class=\"form-restore\">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",
"<br><br>Otherwise you should follow the <a href=\"#\" onclick=\"false\"\n",
"class=\"toggle-register\">standard setup</a>.</p>"))
case "setup":
return template.HTML(fmt.Sprint("<p>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.</p>\n",
"<p>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.</p>\n",
"<p>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.</p>"))
case "root-01":
return template.HTML(fmt.Sprint("<p>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.</p>\n",
"<p>If you want to <b>generate</b> 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.</p>"))
case "issuer-01":
return template.HTML(fmt.Sprint("<p>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.</p>\n",
"<p>If you want to <b>generate</b> 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'.</p>\n"))
case "standalone":
return template.HTML(fmt.Sprint("<p>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 <a href=\"" + r.Header.Get("X-Request-Base") + "/\">home</a>")})
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())
}
}