Skip to content

Instantly share code, notes, and snippets.

@andreimerlescu
Last active June 10, 2023 19:38
Show Gist options
  • Save andreimerlescu/478c8d513a1dad461109eb2cb2f7009e to your computer and use it in GitHub Desktop.
Save andreimerlescu/478c8d513a1dad461109eb2cb2f7009e to your computer and use it in GitHub Desktop.
Go + Gin + A Better Way To Do TLS/SSL

Golang w/ Gin and TLS/SSL Auto-Configuration

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.

main()

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:

  1. 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 a LICENSE file to exist, or be embedded into the program.
  2. Adds support ./myprogram help where the Configurable Package package to display the .Usage() output of the flags and configs that have been defined.
  3. Loads the config.yaml file into memory and parses the ENV + YAML + CLI Flags of the binary making them accessible to any of the config.New* methods that return a pointer to the underlying flag.* type.
  4. Sets the log file of the log package to a file instead of STDOUT
  5. Starts a goroutine for NewWebServer that has two arguments, one a context called ctx and the other a "done channel" called ch_webserver_done.
  6. 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.
  7. Finally, in this file we range over the channels that are open including the ctx.Done() and the ch_webserver_done channel to wait for data to be written to that channel, once data is received, the application safely exits.

NewWebServer()

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:

  1. We are using a sync.Once to ensure that this func is only executed once per runtime.
  2. Using the configurable package, we are setting up the tollbooth package to rate limit incoming requests based on our desired flag values.
  3. 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.
  4. We define but don't configure in this tutorial the middleware_content_security_policy and middleware_cross_origin_resource_sharing middleware functions for gin to handle CORS and CSP.
  5. 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.
  6. 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 a text/css type.
  7. We respond to any PING request with a PNG.
  8. We start a goroutine for the unsecure HTTP server and start the server on the port its configured to listen to.
  9. 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.
  10. Finally, we wait for the main context to complete, thus keeping the two goroutines running unsecure and secure http endpoints running in the background.

data.go

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.

ssl.go

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:

  1. func loadSSLCertificate() tls.Certificate is responsible for returning a TLS Certificate that can be used by Gin.
  2. 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.
  3. 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.
  4. This second part is responsible for generating a new Self Signed certificate that uses your configurable settings for tls-* to issue the cert including ones with multiple domain names and SAN IP addresses for backend services.
  5. 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 the configurable inside the data.go file.
  6. The func getCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) is responsible for utilizing the certificate sync.Mutex in order to return the SSL certificate that can be served to Gin.
  7. 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 the configurable package.
  8. Finally, the func isPEMDecryptorNotFoundError(err error) bool is responsible for checking the format of a given error to simplify the previous funcs.

Helper Functions

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.

CORS Middleware

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()
}

CSP Middleware

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()
}

config.yaml

---
auto-tls: true
tls-company: "My Company LLC"
tls-expires-in: 24
tls-domain-name: "mydomain.com"
secure-port: 8443
unsecure-port: 8080

Conclusion

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.

Packages

Configurable

Projects

Apario Contribution

License

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment