Skip to content

Commit

Permalink
Merge pull request #88 from local-deploy/DL-T-97
Browse files Browse the repository at this point in the history
Generating a self-signed CA certificate and installing it
varrcan authored Jun 15, 2023
2 parents 8426f8f + 2f74729 commit af96a5e
Showing 19 changed files with 834 additions and 38 deletions.
5 changes: 3 additions & 2 deletions .github/scripts/packages/preinstall.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/bin/bash
set -e

mkdir -p "/etc/dl/config-files"
mkdir -p "/usr/share/zsh/vendor-completions"
install -m 0755 -d /etc/apt/keyrings
install -m 0755 -d /etc/dl/config-files
install -m 0755 -d /usr/share/zsh/vendor-completions
1 change: 1 addition & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
@@ -56,6 +56,7 @@ nfpms:
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
- libnss3-tools
scripts:
preinstall: "./.github/scripts/packages/preinstall.sh"
contents:
30 changes: 30 additions & 0 deletions command/cert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package command

import (
"github.com/pterm/pterm"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

var certCmd = &cobra.Command{
Use: "cert",
Short: "CA certificate management",
Long: `Generating and (un)installing a root certificate in Firefox and/or Chrome/Chromium browsers.`,
ValidArgs: []string{"install", "i", "uninstall", "delete"},
}

func certCommand() *cobra.Command {
certCmd.AddCommand(
installCertCommand(),
uninstallCertCommand(),
)
return certCmd
}

func storeCertConfig(status bool) {
viper.Set("ca", status)
err := viper.WriteConfig()
if err != nil {
pterm.FgRed.Println(err)
}
}
92 changes: 92 additions & 0 deletions command/cert_install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package command

import (
"context"
"os"
"path/filepath"

"github.com/local-deploy/dl/helper"
"github.com/local-deploy/dl/utils/cert"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
)

var reinstallCert bool

func installCertCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "install",
Aliases: []string{"i"},
Short: "Installing CA certificate",
Long: `Generating a self-signed CA certificate and installing it in Firefox and/or Chrome/Chromium browsers.`,
Run: func(cmd *cobra.Command, args []string) {
installCertRun()
},
ValidArgs: []string{"--reinstall", "-r"},
}
cmd.Flags().BoolVarP(&reinstallCert, "reinstall", "r", false, "Reinstall certificate")
return cmd
}

func installCertRun() {
certutilPath, err := helper.CertutilPath()
if err != nil {
pterm.FgRed.Printfln("Error: %s", err)
return
}

err = helper.CreateDirectory(filepath.Join(helper.CertDir(), "conf"))
if err != nil {
pterm.FgRed.Printfln("Error: %s \n", err)
os.Exit(1)
}

c := &cert.Cert{
CertutilPath: certutilPath,
CaFileName: cert.CaRootName,
CaFileKeyName: cert.CaRootKeyName,
CaPath: helper.CertDir(),
}

if reinstallCert {
err = c.LoadCA()
if err != nil {
pterm.FgRed.Printfln("Error: %s", err)
return
}
c.Uninstall()
helper.RemoveFilesInPath(filepath.Join(helper.CertDir(), "conf"))
helper.RemoveFilesInPath(helper.CertDir())
}

_, err = os.Stat(filepath.Join(helper.CertDir(), cert.CaRootName))
if err != nil {
err := c.CreateCA()
if err != nil {
pterm.FgRed.Printfln("Error: %s", err)
return
}
}

err = c.LoadCA()
if err != nil {
pterm.FgRed.Printfln("Error: %s", err)
return
}

if c.Check() {
pterm.FgGreen.Println("The local CA is already installed in the browsers trust store!")
} else if c.Install() {
storeCertConfig(true)
pterm.FgGreen.Println("The local CA is now installed in the browsers trust store (requires browser restart)!")

// Restart traefik
source = "traefik"
ctx := context.Background()
_ = downServiceRun(ctx)
err = upServiceRun(ctx)
if err != nil {
pterm.FgYellow.Println("Restart services for changes to take effect: dl service recreate")
}
}
}
58 changes: 58 additions & 0 deletions command/cert_uninstall.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package command

import (
"path/filepath"

"github.com/local-deploy/dl/helper"
"github.com/local-deploy/dl/utils/cert"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

func uninstallCertCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "uninstall",
Short: "Removing CA certificate",
Long: `Removing a self-signed CA certificate from the Firefox and/or Chrome/Chromium browsers.`,
Run: func(cmd *cobra.Command, args []string) {
uninstallCertRun()
},
}
return cmd
}

func uninstallCertRun() {
certutilPath, err := helper.CertutilPath()
if err != nil {
pterm.FgRed.Printfln("Error: %s", err)
return
}

c := &cert.Cert{
CertutilPath: certutilPath,
CaFileName: cert.CaRootName,
CaFileKeyName: cert.CaRootKeyName,
CaPath: helper.CertDir(),
}

err = c.LoadCA()
if err != nil {
pterm.FgRed.Printfln("Error: %s", err)
return
}

ca := viper.GetBool("ca")
if !ca {
pterm.FgYellow.Println("The local CA is not installed")
return
}

c.Uninstall()

helper.RemoveFilesInPath(filepath.Join(helper.CertDir(), "conf"))
helper.RemoveFilesInPath(helper.CertDir())

storeCertConfig(false)
pterm.FgYellow.Println("The local CA is now uninstalled from the browsers trust store!")
}
7 changes: 7 additions & 0 deletions command/down.go
Original file line number Diff line number Diff line change
@@ -4,12 +4,14 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"

"github.com/local-deploy/dl/helper"
"github.com/local-deploy/dl/project"
"github.com/pterm/pterm"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

func downCommand() *cobra.Command {
@@ -30,6 +32,11 @@ func downRun() {

pterm.FgGreen.Printfln("Stopping project...")

if viper.GetBool("ca") {
_ = helper.RemoveDirectory(filepath.Join(helper.CertDir(), "conf", project.Env.GetString("NETWORK_NAME")+".yaml"))
_ = helper.RemoveDirectory(filepath.Join(helper.CertDir(), project.Env.GetString("NETWORK_NAME")))
}

bin, option := helper.GetCompose()
Args := []string{bin}
preArgs := []string{"-p", project.Env.GetString("NETWORK_NAME"), "down"}
4 changes: 2 additions & 2 deletions command/env.go
Original file line number Diff line number Diff line change
@@ -57,7 +57,7 @@ func showEnvMenu() {

func printEnvConfig() {
templateDir := helper.TemplateDir()
src := filepath.Join(templateDir, "/config-files/.env.example")
src := filepath.Join(templateDir, ".env.example")

file, err := os.Open(src)
if err != nil {
@@ -96,7 +96,7 @@ func copyEnv() bool {
if project.IsEnvExampleFileExists() {
src = filepath.Join(currentDir, ".env.example")
} else {
src = filepath.Join(templateDir, "/config-files/.env.example")
src = filepath.Join(templateDir, ".env.example")
}

dest := filepath.Join(currentDir, ".env")
2 changes: 1 addition & 1 deletion command/ps.go
Original file line number Diff line number Diff line change
@@ -58,7 +58,7 @@ func runPs() error {

cli, err := docker.NewClient()
if err != nil {
pterm.Fatal.Printfln("Failed to connect to socket")
pterm.FgRed.Printfln("Failed to connect to socket")
return err
}

1 change: 1 addition & 0 deletions command/root.go
Original file line number Diff line number Diff line change
@@ -43,6 +43,7 @@ func Execute() {
rootCmd.AddCommand(
envCommand(),
psCommand(),
certCommand(),
bashCommand(),
execCommand(),
completionCommand(),
10 changes: 10 additions & 0 deletions command/service.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package command

import (
"path/filepath"

"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/docker/docker/integration/network"
"github.com/local-deploy/dl/helper"
"github.com/local-deploy/dl/utils/docker"
"github.com/spf13/cobra"
)
@@ -40,6 +43,7 @@ func getServicesContainer() []docker.Container {
"--providers.docker",
"--providers.docker.network=dl_default",
"--providers.docker.exposedbydefault=false",
"--providers.file.directory=/certs/conf",
"--entrypoints.web.address=:80",
"--entrypoints.websecure.address=:443",
"--entrypoints.ws.address=:8081",
@@ -64,6 +68,12 @@ func getServicesContainer() []docker.Container {
Target: "/var/run/docker.sock",
ReadOnly: true,
},
{
Type: mount.TypeBind,
Source: filepath.Join(helper.ConfigDir(), "certs"),
Target: "/certs",
ReadOnly: true,
},
},
Env: nil,
Network: servicesNetworkName,
20 changes: 16 additions & 4 deletions command/up.go
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@ import (
"github.com/pterm/pterm"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

func upCommand() *cobra.Command {
@@ -46,12 +47,12 @@ func upRun() {
ctx := context.Background()
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
pterm.Fatal.Printfln("Failed to connect to socket")
pterm.FgRed.Printfln("Failed to connect to socket")
return
}

containerFilter := filters.NewArgs(filters.Arg("name", "traefik"))
traefikExists, err := cli.ContainerList(ctx, types.ContainerListOptions{Filters: containerFilter})
traefikExists, _ := cli.ContainerList(ctx, types.ContainerListOptions{Filters: containerFilter})

if len(traefikExists) == 0 {
err := startLocalServices()
@@ -63,6 +64,11 @@ func upRun() {

pterm.FgGreen.Printfln("Starting project...")

if viper.GetBool("ca") {
pterm.FgGreen.Printfln("SSL certificate enabled")
project.CreateCert()
}

bin, option := helper.GetCompose()
Args := []string{bin}
preArgs := []string{"-p", project.Env.GetString("NETWORK_NAME"), "up", "-d"}
@@ -113,18 +119,24 @@ func startLocalServices() error {
return nil
}
//goland:noinspection GoErrorStringFormat
return errors.New("Start local services first: dl service up")
return errors.New("start local services first: dl service up")
}

// showProjectInfo Display project links
func showProjectInfo() {
l := project.Env.GetString("LOCAL_DOMAIN")
n := project.Env.GetString("NIP_DOMAIN")

schema := "http"

if viper.GetBool("ca") {
schema = "https"
}

pterm.FgCyan.Println()
panels := pterm.Panels{
{{Data: pterm.FgYellow.Sprintf("nip.io\nlocal")},
{Data: pterm.FgYellow.Sprintf("http://%s/\nhttp://%s/", n, l)}},
{Data: pterm.FgYellow.Sprintf(schema+"://%s/\n"+schema+"://%s/", n, l)}},
}

_ = pterm.DefaultPanel.WithPanels(panels).WithPadding(5).Render()
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ require (
golang.org/x/crypto v0.4.0
golang.org/x/sync v0.1.0
golang.org/x/text v0.7.0
gopkg.in/yaml.v3 v3.0.1
)

require (
@@ -99,6 +100,5 @@ require (
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.4.0 // indirect
)
111 changes: 93 additions & 18 deletions helper/functions.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package helper

import (
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"

"github.com/pterm/pterm"
@@ -29,10 +31,10 @@ func ConfigDir() string {
// TemplateDir template directory (~/.config/dl or /etc/dl)
func TemplateDir() string {
if IsAptInstall() {
return filepath.Join("/", "etc", "dl")
return filepath.Join("/", "etc", "dl", "config-files")
}

return ConfigDir()
return filepath.Join(ConfigDir(), "config-files")
}

// binDir path to bin directory
@@ -46,33 +48,88 @@ func binDir() string {
return path.Dir(bin)
}

// CertDir certificate directory
func CertDir() string {
return filepath.Join(ConfigDir(), "certs")
}

// CertutilPath determine the path to the certutil
func CertutilPath() (string, error) {
switch runtime.GOOS {
case "darwin":
switch {
case BinaryExists("certutil"):
certutilPath, _ := exec.LookPath("certutil")
return certutilPath, nil
case BinaryExists("/usr/local/opt/nss/bin/certutil"):
certutilPath := "/usr/local/opt/nss/bin/certutil"
return certutilPath, nil
default:
out, err := exec.Command("brew", "--prefix", "nss").Output()
if err == nil {
certutilPath := filepath.Join(strings.TrimSpace(string(out)), "bin", "certutil")
if pathExists(certutilPath) {
return certutilPath, nil
}
}
}

case "linux":
if BinaryExists("certutil") {
certutilPath, _ := exec.LookPath("certutil")
return certutilPath, nil
}
}

certutilInstallHelp := ""
switch {
case BinaryExists("apt"):
certutilInstallHelp = "apt install libnss3-tools"
case BinaryExists("yum"):
certutilInstallHelp = "yum install nss-tools"
case BinaryExists("zypper"):
certutilInstallHelp = "zypper install mozilla-nss-tools"
}

return "", fmt.Errorf("certutil not found. Please install it: %s", certutilInstallHelp)
}

// BinaryExists check for the existence of a binary file
func BinaryExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}

func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}

// BinPath path to bin
func BinPath() string {
return filepath.Join(binDir(), "dl")
}

// IsAptInstall checking for install from apt
func IsAptInstall() bool {
dir := binDir()

return strings.EqualFold(dir, "/usr/bin")
return strings.EqualFold(binDir(), "/usr/bin")
}

// IsConfigFileExists checking for the existence of a configuration file
func IsConfigFileExists() bool {
confDir := ConfigDir()
config := filepath.Join(confDir, "config.yaml")

_, err := os.Stat(config)
config := filepath.Join(ConfigDir(), "config.yaml")

return err == nil
return pathExists(config)
}

// IsBinFileExists checks the existence of a binary
func IsBinFileExists() bool {
_, err := os.Stat(BinPath())
return pathExists(BinPath())
}

return err == nil
// IsCertPathExists check if the certificate directory exists
func IsCertPathExists() bool {
return pathExists(CertDir())
}

// ChmodR change file permissions recursively
@@ -88,9 +145,8 @@ func ChmodR(path string, mode os.FileMode) error {

// CreateDirectory recursively create directories
func CreateDirectory(path string) error {
_, err := os.Stat(path)
if err != nil {
err = os.MkdirAll(path, 0755)
if !pathExists(path) {
err := os.MkdirAll(path, 0755)
if err != nil {
return err
}
@@ -101,9 +157,8 @@ func CreateDirectory(path string) error {

// RemoveDirectory recursively remove directories
func RemoveDirectory(path string) error {
_, err := os.Stat(path)
if err != nil {
err = os.RemoveAll(path)
if pathExists(path) {
err := os.RemoveAll(path)
if err != nil {
return err
}
@@ -112,6 +167,26 @@ func RemoveDirectory(path string) error {
return nil
}

// RemoveFilesInPath deleting files in a directory
func RemoveFilesInPath(path string) {
if pathExists(path) {
dir, _ := os.ReadDir(path)
if len(dir) > 0 {
for _, dirEntry := range dir {
if dirEntry.IsDir() {
continue
}
childPath := filepath.Join(path, dirEntry.Name())

err := os.RemoveAll(childPath)
if err != nil {
continue
}
}
}
}
}

// GetCompose get link to executable file and arguments
func GetCompose() (string, string) {
if isComposePlugin() {
13 changes: 13 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ import (
"embed"
"os"
"os/exec"
"path/filepath"
"time"

"github.com/local-deploy/dl/command"
@@ -32,6 +33,10 @@ func main() {
firstStart()
}

if !helper.IsCertPathExists() {
createCertDirectory()
}

initConfig()
command.Execute()
}
@@ -104,6 +109,14 @@ func createConfigFile() error {
return errWrite
}

func createCertDirectory() {
err := helper.CreateDirectory(filepath.Join(helper.CertDir(), "conf"))
if err != nil {
pterm.FgRed.Printfln("Unable to create certs directory: %s \n", err)
os.Exit(1)
}
}

func dockerCheck() bool {
_, err := exec.LookPath("docker")
if err != nil {
12 changes: 6 additions & 6 deletions project/env.go
Original file line number Diff line number Diff line change
@@ -74,7 +74,7 @@ func setDefaultEnv() {
Env.SetDefault("NETWORK_NAME", res)

confDir := helper.TemplateDir()
Env.SetDefault("NGINX_CONF", filepath.Join(confDir, "config-files", "default.conf.template"))
Env.SetDefault("NGINX_CONF", filepath.Join(confDir, "default.conf.template"))

customConfig := Env.GetString("NGINX_CONF")
if len(customConfig) > 0 {
@@ -108,11 +108,11 @@ func setComposeFiles() {
templateDir := helper.TemplateDir()

images := map[string]string{
"mysql": templateDir + "/config-files/docker-compose-mysql.yaml",
"fpm": templateDir + "/config-files/docker-compose-fpm.yaml",
"apache": templateDir + "/config-files/docker-compose-apache.yaml",
"redis": templateDir + "/config-files/docker-compose-redis.yaml",
"memcached": templateDir + "/config-files/docker-compose-memcached.yaml",
"mysql": templateDir + "/docker-compose-mysql.yaml",
"fpm": templateDir + "/docker-compose-fpm.yaml",
"apache": templateDir + "/docker-compose-apache.yaml",
"redis": templateDir + "/docker-compose-redis.yaml",
"memcached": templateDir + "/docker-compose-memcached.yaml",
}

phpVersion := Env.GetString("PHP_VERSION")
80 changes: 80 additions & 0 deletions project/ssl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package project

import (
"fmt"
"os"
"path/filepath"

"github.com/local-deploy/dl/helper"
"github.com/local-deploy/dl/utils/cert"
"github.com/pterm/pterm"
"gopkg.in/yaml.v3"
)

type tlsStruct struct {
TSL certStruct `yaml:"tls"`
}

type certStruct struct {
Certificates []certFileStruct `yaml:"certificates"`
}

type certFileStruct struct {
CertFile string `yaml:"certFile"`
KeyFile string `yaml:"keyFile"`
}

// CreateCert create a certificate and key for the project
func CreateCert() {
certutilPath, err := helper.CertutilPath()
if err != nil {
pterm.FgRed.Printfln("Error: %s", err)
return
}

c := &cert.Cert{
CertutilPath: certutilPath,
CaFileName: cert.CaRootName,
CaFileKeyName: cert.CaRootKeyName,
CaPath: helper.CertDir(),
}

err = c.LoadCA()
if err != nil {
pterm.FgRed.Printfln("Error: %s", err)
return
}

// ~/.config/dl/certs/site
certDir := filepath.Join(helper.CertDir(), Env.GetString("NETWORK_NAME"))
_ = helper.CreateDirectory(certDir)

err = c.MakeCert([]string{
Env.GetString("LOCAL_DOMAIN"),
Env.GetString("NIP_DOMAIN"),
}, Env.GetString("NETWORK_NAME"))
if err != nil {
pterm.FgRed.Printfln("Error: %s", err)
}

tls := tlsStruct{
TSL: certStruct{
Certificates: []certFileStruct{
{
CertFile: "/certs/" + Env.GetString("NETWORK_NAME") + "/cert.pem",
KeyFile: "/certs/" + Env.GetString("NETWORK_NAME") + "/key.pem",
},
}}}

yamlData, err := yaml.Marshal(&tls)
if err != nil {
fmt.Println(err)
return
}

// ~/.config/dl/certs/conf/site.localhost.yaml
err = os.WriteFile(filepath.Join(helper.CertDir(), "conf", Env.GetString("NETWORK_NAME")+".yaml"), yamlData, 0600)
if err != nil {
pterm.FgRed.Printfln("failed to create config certificate file: %s", err)
}
}
267 changes: 267 additions & 0 deletions utils/cert/cert.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
package cert

import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1" //nolint:gosec
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"net/url"
"os"
"path/filepath"
"time"

"github.com/local-deploy/dl/helper"
"github.com/pterm/pterm"
)

// CaRootName certificate file name
const CaRootName = "rootCA.pem"

// CaRootKeyName certificate key file name
const CaRootKeyName = "rootCA-key.pem"

// Cert certificate structure
type Cert struct {
CertutilPath string
CaFileName string
CaFileKeyName string
CaPath string
CaCert *x509.Certificate
CaKey crypto.PrivateKey

keyFile, certFile string
}

// LoadCA certificate reading
func (c *Cert) LoadCA() error {
if !pathExists(filepath.Join(c.CaPath, c.CaFileName)) {
return nil
}

certPEMBlock, err := os.ReadFile(filepath.Join(c.CaPath, c.CaFileName))
if err != nil {
return fmt.Errorf("failed to read the CA certificate: %w", err)
}
certDERBlock, _ := pem.Decode(certPEMBlock)
if certDERBlock == nil || certDERBlock.Type != "CERTIFICATE" {
return errors.New("failed to read the CA certificate: unexpected content")
}
c.CaCert, err = x509.ParseCertificate(certDERBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse the CA certificate: %w", err)
}

if !pathExists(filepath.Join(c.CaPath, c.CaFileKeyName)) {
return nil
}

keyPEMBlock, err := os.ReadFile(filepath.Join(c.CaPath, c.CaFileKeyName))
if err != nil {
return fmt.Errorf("failed to read the CA key: %w", err)
}
keyDERBlock, _ := pem.Decode(keyPEMBlock)
if keyDERBlock == nil || keyDERBlock.Type != "PRIVATE KEY" {
return errors.New("failed to read the CA key: unexpected content")
}
c.CaKey, err = x509.ParsePKCS8PrivateKey(keyDERBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse the CA key: %w", err)
}
return nil
}

// CreateCA creating a root certificate
func (c *Cert) CreateCA() error {
privateKey, err := c.generateKey(true)
if err != nil {
return fmt.Errorf("failed to generate the CA key: %w", err)
}
publicKey := privateKey.(crypto.Signer).Public()

pkixPublicKey, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return fmt.Errorf("failed to encode public key: %w", err)
}

var keyIdentifier struct {
Algorithm pkix.AlgorithmIdentifier
SubjectPublicKey asn1.BitString
}
_, err = asn1.Unmarshal(pkixPublicKey, &keyIdentifier)
if err != nil {
return fmt.Errorf("failed to decode public key: %w", err)
}

checksum := sha1.Sum(keyIdentifier.SubjectPublicKey.Bytes) //nolint:gosec

template := &x509.Certificate{
SerialNumber: randomSerialNumber(),
Subject: pkix.Name{
Organization: []string{"Local Deploy CA"},
OrganizationalUnit: []string{"DL Certificate Authority"},
CommonName: "DL Certificate",
},
SubjectKeyId: checksum[:],
NotAfter: time.Now().AddDate(10, 0, 0),
NotBefore: time.Now(),
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLenZero: true,
}

certificate, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey)
if err != nil {
return fmt.Errorf("failed to generate CA certificate: %w", err)
}

pkcs8PrivateKey, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return fmt.Errorf("failed to encode CA key: %w", err)
}
err = os.WriteFile(filepath.Join(c.CaPath, c.CaFileKeyName), pem.EncodeToMemory(
&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8PrivateKey}), 0400)
if err != nil {
return fmt.Errorf("failed to save CA key: %w", err)
}

err = os.WriteFile(filepath.Join(c.CaPath, c.CaFileName), pem.EncodeToMemory( //nolint:gosec
&pem.Block{Type: "CERTIFICATE", Bytes: certificate}), 0644)
if err != nil {
return fmt.Errorf("failed to save CA certificate: %w", err)
}
return nil
}

// MakeCert Create certificates for domains
func (c *Cert) MakeCert(hosts []string, path string) error {
if c.CaKey == nil {
return fmt.Errorf("can't create new certificates because the CA key (%s) is missing", c.CaFileKeyName)
}

privateKey, err := c.generateKey(false)
if err != nil {
return fmt.Errorf("failed to generate certificate key: %w", err)
}
publicKey := privateKey.(crypto.Signer).Public()
expiration := time.Now().AddDate(2, 3, 0)

dir, _ := os.Getwd()
template := &x509.Certificate{
SerialNumber: randomSerialNumber(),
Subject: pkix.Name{
Organization: []string{filepath.Base(dir) + " development certificate"},
OrganizationalUnit: []string{filepath.Base(dir) + " Certificate"},
},

NotBefore: time.Now(), NotAfter: expiration,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
}

for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else if uriName, err := url.Parse(h); err == nil && uriName.Scheme != "" && uriName.Host != "" {
template.URIs = append(template.URIs, uriName)
} else {
template.DNSNames = append(template.DNSNames, h)
}
}

if len(template.IPAddresses) > 0 || len(template.DNSNames) > 0 || len(template.URIs) > 0 {
template.ExtKeyUsage = append(template.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
}

cert, err := x509.CreateCertificate(rand.Reader, template, c.CaCert, publicKey, c.CaKey)
if err != nil {
return fmt.Errorf("failed to generate certificate: %w", err)
}

certFile, keyFile := c.fileNames()

certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert})
pkcs8PrivateKey, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return fmt.Errorf("failed to encode certificate key: %w", err)
}
privatePEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8PrivateKey})

if certFile == keyFile {
err = os.WriteFile(filepath.Join(helper.CertDir(), path, keyFile), append(certPEM, privatePEM...), 0600)
if err != nil {
return fmt.Errorf("failed to save certificate and key: %w", err)
}
} else {
err = os.WriteFile(filepath.Join(helper.CertDir(), path, certFile), certPEM, 0644) //nolint:gosec
if err != nil {
return fmt.Errorf("failed to save certificate: %w", err)
}
err = os.WriteFile(filepath.Join(helper.CertDir(), path, keyFile), privatePEM, 0600)
if err != nil {
return fmt.Errorf("failed to save certificate key: %w", err)
}
}

// TODO: add debug

// if certFile == keyFile {
// log.Printf("\nThe certificate and key are at \"%s\"\n\n", certFile)
// } else {
// log.Printf("\nThe certificate is at \"%s\" and the key at \"%s\"\n\n", certFile, keyFile)
// }
//
// log.Printf("It will expire on %s\n\n", expiration.Format("2 January 2006"))
return nil
}

func (c *Cert) fileNames() (certFile, keyFile string) {
certFile = "./cert.pem"
if c.certFile != "" {
certFile = c.certFile
}
keyFile = "./key.pem"
if c.keyFile != "" {
keyFile = c.keyFile
}

return
}

func (c *Cert) generateKey(rootCA bool) (crypto.PrivateKey, error) {
if rootCA {
return rsa.GenerateKey(rand.Reader, 3072)
}
return rsa.GenerateKey(rand.Reader, 2048)
}

func (c *Cert) verifyCert() bool {
_, err := c.CaCert.Verify(x509.VerifyOptions{})
return err == nil
}

func (c *Cert) caUniqueName() string {
return "DL development CA " + c.CaCert.SerialNumber.String()
}

func randomSerialNumber() *big.Int {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
pterm.FgRed.Printfln("failed to generate serial number: %s", err)
os.Exit(1)
}
return serialNumber
}

func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
150 changes: 150 additions & 0 deletions utils/cert/store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package cert

import (
"bytes"
"os"
"os/exec"
"os/user"
"path/filepath"
"sync"

"github.com/local-deploy/dl/helper"
"github.com/pterm/pterm"
)

var (
nssDBs = []string{
filepath.Join(os.Getenv("HOME"), ".pki/nssdb"),
filepath.Join(os.Getenv("HOME"), "snap/chromium/current/.pki/nssdb"), // Snapcraft
"/etc/pki/nssdb", // CentOS 7
}
firefoxProfiles = []string{
os.Getenv("HOME") + "/.mozilla/firefox/*",
os.Getenv("HOME") + "/snap/firefox/common/.mozilla/firefox/*",
}
firefoxPaths = []string{
"/usr/bin/firefox",
"/usr/bin/firefox-nightly",
"/usr/bin/firefox-developer-edition",
"/snap/firefox",
"/Applications/Firefox.app",
"/Applications/FirefoxDeveloperEdition.app",
"/Applications/Firefox Developer Edition.app",
"/Applications/Firefox Nightly.app",
}
)

func hasBrowser() bool {
allPaths := append(append([]string{}, nssDBs...), firefoxPaths...)
for _, path := range allPaths {
if pathExists(path) {
return true
}
}
return false
}

// Check if the certificate is installed
func (c *Cert) Check() bool {
success := true
if c.forEachProfile(func(profile string) {
err := exec.Command(c.CertutilPath, "-V", "-d", profile, "-u", "L", "-n", c.caUniqueName()).Run() //nolint:gosec
if err != nil {
success = false
}
}) == 0 {
success = false
}
return success
}

// Install certificate installation
func (c *Cert) Install() bool {
if c.forEachProfile(func(profile string) {
cmd := exec.Command(c.CertutilPath, "-A", "-d", profile, "-t", "C,,", "-n", c.caUniqueName(), "-i", filepath.Join(c.CaPath, c.CaFileName)) //nolint:gosec
out, err := execCertutil(cmd)
if err != nil {
pterm.FgRed.Printfln("Error: failed to execute \"%s\": %s\n\n%s\n", "certutil -A -d "+profile, err, out)
os.Exit(1)
}
}) == 0 {
pterm.FgRed.Println("Error: no browsers security databases found")
return false
}
if !c.Check() {
pterm.FgRed.Println("Installing in browsers failed. Please report the issue with details about your environment at https://github.com/local-deploy/dl/issues/new")
pterm.FgYellow.Println("Note that if you never started browsers, you need to do that at least once.")
return false
}
return true
}

// Uninstall deleting certificate
func (c *Cert) Uninstall() {
c.forEachProfile(func(profile string) {
err := exec.Command(c.CertutilPath, "-V", "-d", profile, "-u", "L", "-n", c.caUniqueName()).Run() //nolint:gosec
if err != nil {
return
}
cmd := exec.Command(c.CertutilPath, "-D", "-d", profile, "-n", c.caUniqueName()) //nolint:gosec
out, err := execCertutil(cmd)
if err != nil {
pterm.FgRed.Printfln("Error: failed to execute \"%s\": %s\n\n%s\n", "certutil -D -d "+profile, err, out)
}
})
}

func execCertutil(cmd *exec.Cmd) ([]byte, error) {
out, err := cmd.CombinedOutput()
if err != nil && bytes.Contains(out, []byte("SEC_ERROR_READ_ONLY")) {
origArgs := cmd.Args[1:]
cmd = commandWithSudo(cmd.Path)
cmd.Args = append(cmd.Args, origArgs...)
out, err = cmd.CombinedOutput()
}
return out, err
}

func (c *Cert) forEachProfile(f func(profile string)) (found int) {
var profiles []string
profiles = append(profiles, nssDBs...)
for _, ff := range firefoxProfiles {
pp, _ := filepath.Glob(ff)
profiles = append(profiles, pp...)
}
for _, profile := range profiles {
if stat, err := os.Stat(profile); err != nil || !stat.IsDir() {
continue
}
if pathExists(filepath.Join(profile, "cert9.db")) {
f("sql:" + profile)
found++
} else if pathExists(filepath.Join(profile, "cert8.db")) {
f("dbm:" + profile)
found++
}
}
return
}

var sudoWarningOnce sync.Once

func commandWithSudo(cmd ...string) *exec.Cmd {
u, err := user.Current()
if err == nil && u.Uid == "0" {
return exec.Command(cmd[0], cmd[1:]...) //nolint:gosec
}
if !helper.BinaryExists("sudo") {
sudoWarningOnce.Do(func() {
pterm.FgRed.Println(`Warning: "sudo" is not available, and dl is not running as root. The (un)install operation might fail.️`)
})
return exec.Command(cmd[0], cmd[1:]...) //nolint:gosec
}

userName := "user"
if u != nil && len(u.Username) > 0 {
userName = u.Username
}

return exec.Command("sudo", append([]string{"--prompt=[sudo] password for " + userName + ":", "--"}, cmd...)...) //nolint:gosec
}
7 changes: 3 additions & 4 deletions utils/templates.go
Original file line number Diff line number Diff line change
@@ -14,17 +14,16 @@ var Templates embed.FS
// CreateTemplates create docker-compose files
func CreateTemplates(overwrite bool) error {
templateDir := helper.TemplateDir()
configDir := filepath.Join(templateDir, "config-files")

// delete existing directory
if overwrite {
err := helper.RemoveDirectory(configDir)
err := helper.RemoveDirectory(templateDir)
if err != nil {
return err
}
}

err := helper.CreateDirectory(configDir)
err := helper.CreateDirectory(templateDir)
if err != nil {
return err
}
@@ -35,7 +34,7 @@ func CreateTemplates(overwrite bool) error {
}

for _, entry := range entries {
out, err := os.Create(filepath.Join(configDir, entry.Name()))
out, err := os.Create(filepath.Join(templateDir, entry.Name()))
if err != nil {
return err
}

0 comments on commit af96a5e

Please sign in to comment.