Sometimes you need SSL, and sometimes you want to use a self signed certificate, and sometimes you want to handle certificate rotations behind the scenes without restarting the application. This tutorial will introduce an alternative way to serve TLS/SSL with Go and Gin by leveraging a few packages that I've created to provide a seemless experience to the engineer setting up TLS for their Go Gin application.
package main
import (
`context`
`flag`
`fmt`
`log`
`os`
`os/signal`
`path/filepath`
`strings`
`syscall`
`text/tabwriter`
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
for _, arg := range os.Args {
if arg == "help" {
log.Println(config.Usage())
os.Exit(0)
}
if arg == "show" {
for _, innerArg := range os.Args {
if innerArg == "w" || innerArg == "c" {
license, err := os.ReadFile(filepath.Join(".", "LICENSE"))
if err != nil {
fmt.Printf("Cannot find the license file to load to comply with the GNU-3 license terms. This program was modified outside of its intended runtime use.")
os.Exit(1)
} else {
fmt.Printf("%v\n", string(license))
os.Exit(1)
}
}
}
}
}
configErr := config.Parse("config.yaml")
if configErr != nil {
log.Fatalf("failed to parse config file: %v", configErr)
}
logFile, logFileErr := os.OpenFile(*flag_s_log_file, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0666)
if logFileErr != nil {
log.Fatalf("Failed to open log file: %v", logFileErr)
}
log.SetOutput(logFile)
go NewWebServer(ctx, ch_webserver_done)
watchdog := make(chan os.Signal, 1)
signal.Notify(watchdog, os.Kill, syscall.SIGTERM, os.Interrupt)
go func() {
<-watchdog
err := logFile.Close()
if err != nil {
log.Printf("failed to close the logFile due to error: %v", err)
}
close(ch_cert_reloader_cancel)
close(ch_webserver_done)
fmt.Println("Program killed!")
os.Exit(0)
}()
for {
select {
case <-ctx.Done():
fatalf_stout("Main context canceled, exiting application now. Reason: %v", ctx.Err())
case <-ch_webserver_done:
cancel()
}
}
}
This main()
is doing a few things:
- Adds support for GPL-3 conventions to get the license information by typing in the name of the executable- such as "myprogram" as
./myprogram c
and you can see the license of the project. It requires aLICENSE
file to exist, or be embedded into the program. - Adds support
./myprogram help
where the Configurable Package package to display the.Usage()
output of the flags and configs that have been defined. - Loads the
config.yaml
file into memory and parses the ENV + YAML + CLI Flags of the binary making them accessible to any of theconfig.New*
methods that return a pointer to the underlyingflag.*
type. - Sets the log file of the
log
package to a file instead of STDOUT - Starts a goroutine for
NewWebServer
that has two arguments, one a context calledctx
and the other a "done channel" calledch_webserver_done
. - We configure a Signal Interrupt watchdog to respond to Ctrl+C and other process exit types to properly close the log file and close the channels.
- Finally, in this file we range over the channels that are open including the
ctx.Done()
and thech_webserver_done
channel to wait for data to be written to that channel, once data is received, the application safely exits.
func NewWebServer(ctx context.Context, ch_done chan struct{}) {
once_server_start.Do(func() {
defer func() {
ch_done <- struct{}{}
}()
// Rate Limiting
defaultRateLimiter := tollbooth.NewLimiter(*flag_f_rate_limit, &limiter.ExpirableOptions{
DefaultExpirationTTL: time.Duration(*flag_i_rate_limit_entry_ttl) * time.Second,
ExpireJobInterval: time.Duration(*flag_i_rate_limit_cleanup_delay) * time.Second,
})
assetRateLimiter := tollbooth.NewLimiter(*flag_f_asset_rate_limit, &limiter.ExpirableOptions{
DefaultExpirationTTL: time.Duration(*flag_i_asset_rate_limit_entry_ttl) * time.Second,
ExpireJobInterval: time.Duration(*flag_i_asset_rate_limit_cleanup_delay) * time.Second,
})
// Logging
log_file_gin, log_file_err := os.OpenFile(filepath.Join(".", "logs", "gin.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if log_file_err != nil {
log.Fatal("Failed to open log log_file_gin:", log_file_err)
}
defer log_file_gin.Close()
// Web Server Configuration
r := gin.Default()
r.Use(func(c *gin.Context) {
requestLog := log.New(log_file_gin, "[GIN] ", log.LstdFlags)
gin.DefaultWriter = requestLog.Writer()
c.Next()
log_file_sync_err := log_file_gin.Sync()
if log_file_sync_err != nil {
log.Println("Failed to flush log entries:", log_file_sync_err)
}
})
r.Use(middleware_content_security_policy)
r.Use(middleware_cross_origin_resource_sharing)
r.Use(tollbooth_gin.LimitHandler(defaultRateLimiter))
// When a proxy is used, configure it here, and forward the client IP to the application
f_proxies := *flag_s_trusted_proxies
var trusted_proxies []string
if strings.Contains(f_proxies, ",") {
f_proxies = strings.ReplaceAll(f_proxies, " ", "")
proxies := strings.Split(f_proxies, ",")
for _, proxy := range proxies {
proxy_check := net.ParseIP(proxy)
if proxy_check != nil {
trusted_proxies = append(trusted_proxies, proxy)
}
}
}
err := r.SetTrustedProxies(trusted_proxies)
if err != nil {
return
}
// Serve all static assets using this entry point
r.GET("/asset/:dir/:name", tollbooth_gin.LimitHandler(assetRateLimiter), getAsset)
// Respond to a basic ping
r.Any("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "pong",
})
})
// Start HTTP Server
go func() {
server := &http.Server{
Addr: ":" + strconv.Itoa(*flag_i_webserver_default_port),
Handler: r,
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fatalf_log("listen: %s\n", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
fatalf_stderr("Server Shutdown Failed:%+v", err)
}
log.Println("Server exiting properly")
}()
// Start HTTPS Server
go func() {
cert = loadSSLCertificate()
startCertReloader()
server := &http.Server{
Addr: ":" + strconv.Itoa(*flag_i_webserver_secure_port),
Handler: r,
TLSConfig: &tls.Config{
GetCertificate: getCertificate,
},
}
go func() {
if err := server.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
fatalf_stderr("ListenAndServeTLS(): %s", err)
}
}()
<-ctx.Done()
ctxShutDown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctxShutDown); err != nil {
fatalf_stderr("Server forced to shutdown: %s", err)
}
log.Println("Server exiting properly")
}()
// Wait for the main context to be canceled
for {
select {
case <-ctx.Done():
return
}
}
})
}
This funcion does a lot and can be easily broken down to the following points:
- We are using a
sync.Once
to ensure that this func is only executed once per runtime. - Using the
configurable
package, we are setting up thetollbooth
package to rate limit incoming requests based on our desired flag values. - Then we configure a new log file for the gin webserver logs to write to that is separate from our
main()
log file. We are setting up a middleware func to handle the logging and prefix the lines with the proper statements. - We define but don't configure in this tutorial the
middleware_content_security_policy
andmiddleware_cross_origin_resource_sharing
middleware functions for gin to handle CORS and CSP. - We use the
configurable
package to define the trusted proxies for the gin server so if we are using a reverse proxy like HA Proxy or Nginx, we can set the IP of that host to the trusted proxies list and the client IP will forward correctly to the webserver. - We set up a second rate limiter for assets that need to be compiled and delivered; these assets are specifically for JS/CSS files that are "compiled" where they use Go injected variables and syntax and the end result is either an
text/javascript
or atext/css
type. - We respond to any PING request with a PNG.
- We start a goroutine for the unsecure HTTP server and start the server on the port its configured to listen to.
- We start another goroutine for the secure HTTP server. In this we use a few new functions that will give us the desired effect we're looking to achieve.
- Finally, we wait for the main context to complete, thus keeping the two goroutines running unsecure and secure http endpoints running in the background.
package main
import (
`crypto/tls`
`fmt`
`path/filepath`
`sync`
`time`
`github.com/andreimerlescu/configurable`
)
var (
startedAt = time.Now().UTC()
config = configurable.New()
s_default_log_file = filepath.Join(".", "logs", fmt.Sprintf("pws-%04d-%02d-%02d-%02d-%02d-%02d.log", startedAt.Year(), startedAt.Month(), startedAt.Day(), startedAt.Hour(), startedAt.Minute(), startedAt.Second()))
// Flags
flag_i_webserver_default_port = config.NewInt("unsecure-port", 8080, "Port to start non-SSL version of application.")
flag_i_webserver_secure_port = config.NewInt("secure-port", 8443, "Port to start the SSL version of the application.")
flag_s_ssl_public_key = config.NewString("tls-public-key", "", "Path to the SSL certificate's public key. It expects any CA chain certificates to be concatenated at the end of this PEM formatted file.")
flag_s_ssl_private_key = config.NewString("tls-private-key", "", "Path to the PEM formatted SSL certificate's private key.")
flag_s_ssl_private_key_password = config.NewString("tls-private-key-password", "", "If the PEM private key is encrypted with a password, provide it here.")
flag_b_auto_ssl = config.NewBool("auto-tls", false, "Create a self-signed certificate on the fly and use it for serving the application over SSL.")
flag_i_reload_cert_every_minutes = config.NewInt("tls-life-min", 72, "Lifespan of the auto generated self signed TLS certificate in minutes.")
flag_i_auto_ssl_default_expires = config.NewInt("tls-expires-in", 365*24, "Auto generated TLS/SSL certificates will automatically expire in hours.")
flag_s_auto_ssl_company = config.NewString("tls-company", "ACME Inc.", "Auto generated TLS/SSL certificates are configured with the company name.")
flag_s_auto_ssl_domain_name = config.NewString("tls-domain-name", "", "Auto generated TLS/SSL certificates will have this common name and run on this domain name.")
flag_s_auto_ssl_san_ip = config.NewString("tls-san-ip", "", "Auto generated TLS/SSL certificates will have this SAN IP address attached to it in addition to its common name.")
flag_s_auto_ssl_additional_domains = config.NewString("tls-additional-domains", "", "Auto generated TLS/SSL certificates will be issued with these additional domains (CSV formatted).")
flag_f_rate_limit = config.NewFloat64("rate-limit", 12.0, "Requests per second (0.5 = 1 request every 2 seconds).")
flag_i_rate_limit_cleanup_delay = config.NewInt("rate-limit-cleanup", 3, "Seconds between rate limit cleanups.")
flag_i_rate_limit_entry_ttl = config.NewInt("rate-limit-ttl", 3, "Seconds a rate limit entry exists for before cleanup is triggered.")
flag_f_asset_rate_limit = config.NewFloat64("rate-limit-asset", 36.0, "Requests per second (0.5 = 1 request every 2 seconds).")
flag_i_asset_rate_limit_cleanup_delay = config.NewInt("rate-limit-asset-cleanup", 17, "Seconds between rate limit cleanups.")
flag_i_asset_rate_limit_entry_ttl = config.NewInt("rate-limit-asset-ttl", 17, "Seconds a rate limit entry exists for before cleanup is triggered.")
flag_s_log_file = config.NewString("log", s_default_log_file, "Path to the log file. Truncates file at start.")
flag_s_trusted_proxies = config.NewString("trusted-proxies", "", "Configure the web server to forward client IP addresses to the application if a proxy is used such as Nginx; set that proxy's IP here.")
// Sync
mu_cert = &sync.RWMutex{}
once_server_start = sync.Once{}
// TLS
cert tls.Certificate
// Channels
ch_cert_reloader_cancel = make(chan bool)
ch_webserver_done = make(chan struct{})
)
In this file we are using the configurable
package to define the flags that we want to support either being passed in via CLI arguments or as key/value pairs in a YAML, INI, or JSON file. In addition to these formats, we can also override with ENV vars.
package main
import (
`crypto`
`crypto/ecdsa`
`crypto/elliptic`
`crypto/rand`
`crypto/rsa`
`crypto/tls`
`crypto/x509`
`crypto/x509/pkix`
`encoding/pem`
`errors`
`io`
`log`
`math/big`
`net`
`net/url`
`os`
`strings`
`time`
pkcs8 "github.com/youmark/pkcs8"
`golang.org/x/crypto/ed25519`
)
func loadSSLCertificate() tls.Certificate {
var cert tls.Certificate
var err error
// configure ssl
if *flag_s_ssl_public_key != "" && *flag_s_ssl_private_key != "" {
cert, err = tls.LoadX509KeyPair(*flag_s_ssl_public_key, *flag_s_ssl_private_key)
if err != nil {
if isPEMDecryptorNotFoundError(err) {
decryptedKey, decryptErr := decryptPrivateKey(*flag_s_ssl_private_key, *flag_s_ssl_private_key_password)
if decryptErr != nil {
fatalf_stderr("Failed to decrypt private key: %v", decryptErr)
}
var pemBlock *pem.Block
switch key := decryptedKey.(type) {
case *rsa.PrivateKey:
pemBlock = &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}
case *ecdsa.PrivateKey:
ecPrivateKeyBytes, _ := x509.MarshalECPrivateKey(key)
pemBlock = &pem.Block{
Type: "EC PRIVATE KEY",
Bytes: ecPrivateKeyBytes,
}
case ed25519.PrivateKey:
pemBlock = &pem.Block{
Type: "OPENSSH PRIVATE KEY",
Bytes: key,
}
default:
fatalf_stderr("Unsupported key type: %T", decryptedKey)
}
privateKeyPem := pem.EncodeToMemory(pemBlock)
var errKeyPair error
cert, errKeyPair = tls.X509KeyPair([]byte(*flag_s_ssl_public_key), privateKeyPem)
if errKeyPair != nil {
fatalf_stderr("Failed to load X509 key pair: %v", errKeyPair)
}
}
}
}
// configure auto-tls
if err != nil || *flag_b_auto_ssl {
privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
notBefore := time.Now()
notAfter := notBefore.Add(time.Duration(*flag_i_auto_ssl_default_expires) * time.Hour)
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, _ := rand.Int(rand.Reader, serialNumberLimit)
var valid_ips []net.IP
if strings.Contains(*flag_s_auto_ssl_san_ip, ",") {
// assume CSV entry of IP addresses
flag_ips := strings.ReplaceAll(*flag_s_auto_ssl_san_ip, " ", "")
ips := strings.Split(flag_ips, ",")
for _, ip := range ips {
parsed_ip := net.ParseIP(ip)
if parsed_ip == nil {
fatalf_stderr("failed to parse the ip address %v", *flag_s_auto_ssl_san_ip)
}
valid_ips = append(valid_ips, parsed_ip)
}
}
var valid_domains []string
valid_domains = append(valid_domains, *flag_s_auto_ssl_domain_name)
if strings.Contains(*flag_s_auto_ssl_additional_domains, ",") {
flag_domains := strings.ReplaceAll(*flag_s_auto_ssl_additional_domains, " ", "")
domains := strings.Split(flag_domains, ",")
for _, domain := range domains {
_, err := url.Parse(domain)
if err != nil {
log.Printf("Invalid domain: %s\n", domain)
} else {
valid_domains = append(valid_domains, domain)
}
}
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{*flag_s_auto_ssl_company},
CommonName: *flag_s_auto_ssl_domain_name,
},
NotBefore: notBefore,
NotAfter: notAfter,
IPAddresses: valid_ips,
DNSNames: valid_domains,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
derBytes, _ := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
cert = tls.Certificate{
Certificate: [][]byte{derBytes},
PrivateKey: privateKey,
}
}
return cert
}
func startCertReloader() {
ticker := time.NewTicker(time.Duration(*flag_i_reload_cert_every_minutes) * time.Minute)
go func() {
for {
select {
case <-ticker.C:
newCert := loadSSLCertificate()
mu_cert.Lock()
cert = newCert
mu_cert.Unlock()
case <-ch_cert_reloader_cancel:
ticker.Stop()
return
}
}
}()
}
func getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
mu_cert.RLock()
defer mu_cert.RUnlock()
return &cert, nil
}
func decryptPrivateKey(privateKeyPath, password string) (crypto.PrivateKey, error) {
keyFile, err := os.Open(privateKeyPath)
if err != nil {
return nil, err
}
defer keyFile.Close()
var keyBytes []byte
buf := make([]byte, 1024)
for {
n, err := keyFile.Read(buf)
if err != nil && err != io.EOF {
return nil, err
}
if n == 0 {
break
}
keyBytes = append(keyBytes, buf[:n]...)
}
pemBlock, _ := pem.Decode(keyBytes)
if pemBlock == nil {
return nil, errors.New("could not decode PEM block of private key")
}
if strings.Contains(string(keyBytes), "ENCRYPTED") {
privKey, err := pkcs8.ParsePKCS8PrivateKey(pemBlock.Bytes, []byte(password))
if err != nil {
return nil, err
}
return privKey, nil
} else {
privKey, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes)
if err != nil {
return nil, err
}
return privKey, nil
}
}
func isPEMDecryptorNotFoundError(err error) bool {
return strings.Contains(err.Error(), "x509: decryption password incorrect")
}
This file does quite a bit and we are going to break it down piece by piece for you here:
func loadSSLCertificate() tls.Certificate
is responsible for returning a TLS Certificate that can be used by Gin.- First thing it tries to do is use the specified PEM filepaths of the existing certs and try to use those first. If the cert is valid and the cert works, it will be served. This is where you'd provide your signed CA certs for public consumption.
- Second thing it tries to do is handle a fall-back scenario when that cert isnt found or it doesn't load properly but still serve HTTPS content.
- This second part is responsible for generating a new Self Signed certificate that uses your
configurable
settings fortls-*
to issue the cert including ones with multiple domain names and SAN IP addresses for backend services. - The func
startCertReloader()
is responsible for starting a timer and using a select on a channel that is responsible for reissuing the self signed certificate based on a cadence defined in theconfigurable
inside thedata.go
file. - The func
getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error)
is responsible for utilizing the certificatesync.Mutex
in order to return the SSL certificate that can be served to Gin. - The func
decryptPrivateKey(privateKeyPath, password string) (crypto.PrivateKey, error)
is responsible for decrypting a private key using a password that can be passed into the application using theconfigurable
package. - Finally, the func
isPEMDecryptorNotFoundError(err error) bool
is responsible for checking the format of a given error to simplify the previous funcs.
func fatalf_log(f string, args ...interface{}) {
log.Printf(f, args...)
ch_webserver_done <- struct{}{}
}
func fatalf_stderr(f string, args ...interface{}) {
log.Printf(f, args...)
fatalf_log(f, args...)
}
func fatalf_stout(f string, args ...interface{}) {
log.Printf(f, args...)
fatalf_log(f, args...)
}
There are a handful of helping funcs that are used throughout this code and its useful to capture them here.
var corsMiddleware = func() gin.HandlerFunc {
config := cors.DefaultConfig()
config.AllowOrigins = []string{"http://google.com"}
config.AllowMethods = []string{"GET", "POST", "OPTIONS", "PUT"}
config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type"}
config.ExposeHeaders = []string{"Content-Length"}
config.AllowCredentials = true
config.MaxAge = 12 * time.Hour
return cors.New(config)
}()
func middleware_cross_origin_resource_sharing(c *gin.Context) {
corsMiddleware(c)
c.Next()
}
func middleware_content_security_policy(c *gin.Context) {
domains := []string{"https://ko-fi.com", "https://storage.ko-fi.com"}
wsDomains := []string{}
thirdPartyStyles := []string{"*.ssl-images-amazon.com", "*.amazon-adsystem.com", "*.amazon-adsystem.com", "*.media-amazon.com"}
thirdParty := []string{"js.stripe.com", "storage.ko-fi.com", "ko-fi.com", "*.ssl-images-amazon.com", "*.amazon-adsystem.com", "*.amazon-adsystem.com", "*.media-amazon.com"}
c.Writer.Header().Set("Content-Security-Policy",
"default-src 'self' "+strings.Join(domains, " ")+" "+strings.Join(thirdParty, " ")+";"+
"font-src 'self' data: "+strings.Join(domains, " ")+";"+
"img-src 'self' data: blob: "+strings.Join(domains, " ")+" "+strings.Join(thirdParty, " ")+";"+
"object-src 'self' "+strings.Join(domains, " ")+" "+strings.Join(thirdParty, " ")+";"+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' "+strings.Join(domains, " ")+" "+strings.Join(thirdParty, " ")+";"+
"frame-src 'self' "+strings.Join(domains, " ")+" "+strings.Join(thirdParty, " ")+";"+
"child-src 'self' 'unsafe-inline' blob: data: "+strings.Join(domains, " ")+" "+strings.Join(thirdParty, " ")+";"+
"style-src data: 'unsafe-inline' "+strings.Join(domains, " ")+" "+strings.Join(thirdPartyStyles, " ")+";"+
"connect-src 'self' blob: "+strings.Join(domains, " ")+" "+strings.Join(wsDomains, " ")+";"+
"report-uri /security/csp-report;"+
"upgrade-insecure-requests;"+
"block-all-mixed-content;")
c.Next()
}
---
auto-tls: true
tls-company: "My Company LLC"
tls-expires-in: 24
tls-domain-name: "mydomain.com"
secure-port: 8443
unsecure-port: 8080
This tutorial is not going to hold your hand but it is going to demonstrate to you the awesome power that Go can deliver for web application development. Being able to easily serve SSL certificates in this manner can help you build out services that depend on SSL such as 2FA with OTP and other secure based connections.
This software is licensed under GPL-3 and is owned by Project Apario LLC. If you're also developing a GPL-3 based Go project, feel free to take what you want from the Apario Contribution project and even contribute back up to it if you're a Go developer. If you want to request to be a developer/collaborator with the ability to merge pull requests, let's build a professional working relationship on a pro-bono basis. I am unpaid by Project Apario LLC and I contribute work to the project freely and have done so for years. I also own the organization too, which is key to disclose; but I am spending my free time writing awesome software to solve real world problems. If you want to help me solve real world problems, then feel free to contribute to any of the Project Apario repositories on Github.