diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..91e27c5
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,4 @@
+Dockerfile
+certgraph
+build/
+*.json
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index c167f4f..f353dbc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,3 @@
-go.sum
 certgraph
 build/
 *.json
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..8735826
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,11 @@
+FROM golang:alpine
+
+RUN apk add --update git make
+
+WORKDIR /src/certgraph
+ADD . .
+
+ENV CGO_ENABLED=0
+RUN make install
+
+ENTRYPOINT [ "certgraph" ]
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 178312e..94f6985 100644
--- a/Makefile
+++ b/Makefile
@@ -1,18 +1,18 @@
 GIT_DATE := $(shell git log -1 --date=short --pretty='%cd' | tr -d -)
 GIT_HASH := $(shell git rev-parse HEAD)
 
-BUILD_FLAGS := -ldflags "-X main.gitDate=$(GIT_DATE) -X main.gitHash=$(GIT_HASH)"
+BUILD_FLAGS := -trimpath -ldflags "-w -s -X main.gitDate=$(GIT_DATE) -X main.gitHash=$(GIT_HASH)"
 
 PLATFORMS := linux/amd64 linux/386 linux/arm darwin/amd64 windows/amd64 windows/386 openbsd/amd64
 SOURCES := $(shell find . -maxdepth 1 -type f -name "*.go")
-ALL_SOURCES = $(shell find . -type f -name '*.go')
+ALL_SOURCES = $(shell find . -type f -name '*.go') go.mod web/index_html.go
 
 temp = $(subst /, ,$@)
 os = $(word 1, $(temp))
 arch = $(word 2, $(temp))
 ext = $(shell if [ "$(os)" = "windows" ]; then echo ".exe"; fi)
 
-.PHONY: all release fmt clean serv $(PLATFORMS)
+.PHONY: all release fmt clean serv $(PLATFORMS) docker check
 
 all: certgraph
 
@@ -26,6 +26,12 @@ $(PLATFORMS): $(SOURCES)
 	CGO_ENABLED=0 GOOS=$(os) GOARCH=$(arch) go build $(BUILD_FLAGS) -o 'build/bin/$(os)/$(arch)/certgraph$(ext)' $(SOURCES)
 	mkdir -p build/$(GIT_DATE)/; cd build/bin/$(os)/$(arch)/; zip -r ../../../$(GIT_DATE)/certgraph-$(os)-$(arch)-$(GIT_DATE).zip .; cd ../../../
 
+web/index_html.go: docs/index.html
+	go generate -x ./...
+
+docker: Dockerfile $(ALL_SOURCES)
+	docker build -t lanrat/certgraph .
+
 fmt:
 	gofmt -s -w -l .
 
@@ -33,7 +39,21 @@ install: $(SOURCES) $(ALL_SOURCES)
 	go install $(BUILD_FLAGS)
 
 clean:
-	rm -r certgraph build/
+	rm -rf certgraph build/ web/index_html.go
+
+check: | lint check1 check2
+
+check1:
+	golangci-lint run
+
+check2:
+	staticcheck -f stylish -checks all ./...
+
+lint:
+	golint ./...
+
+serv: certgraph
+	./certgraph --serve 127.0.0.1:8080
 
-serv:
-	(cd docs; python -m SimpleHTTPServer)
+updateMod:
+	go get -u
diff --git a/README.md b/README.md
index e83f643..02f2602 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@
 # CertGraph
-### A tool to crawl the graph of certificate Alternate Names
+
+## A tool to crawl the graph of certificate Alternate Names
 
 CertGraph crawls SSL certificates creating a directed graph where each domain is a node and the certificate alternative names for that domain's certificate are the edges to other domain nodes. New domains are printed as they are found. In Detailed mode upon completion the Graph's adjacency list is printed.
 
@@ -10,57 +11,62 @@ This tool was designed to be used for host name enumeration via SSL certificates
 [Blog post with more information](https://lanrat.com/certgraph/)
 
 ## Usage
-```
+
+```console
 Usage of ./certgraph: [OPTION]... HOST...
-	https://github.com/lanrat/certgraph
+        https://github.com/lanrat/certgraph
 OPTIONS:
+  -apex
+        for every domain found, add the apex domain of the domain's parent
   -cdn
-    	include certificates from CDNs
+        include certificates from CDNs
   -ct-expired
-    	include expired certificates in certificate transparency search
+        include expired certificates in certificate transparency search
   -ct-subdomains
-    	include sub-domains in certificate transparency search
+        include sub-domains in certificate transparency search
   -depth uint
-    	maximum BFS depth to go (default 5)
+        maximum BFS depth to go (default 5)
   -details
-    	print details about the domains crawled
+        print details about the domains crawled
+  -dns
+        check for DNS records to determine if domain is registered
   -driver string
-    	driver to use [crtsh, google, http, smtp] (default "http")
+        driver to use [crtsh, google, http, smtp] (default "http")
   -json
-    	print the graph as json, can be used for graph in web UI
-  -ns
-    	check for NS records to determine if domain is registered
+        print the graph as json, can be used for graph in web UI
   -parallel uint
-    	number of certificates to retrieve in parallel (default 10)
+        number of certificates to retrieve in parallel (default 10)
   -sanscap int
-    	maximum number of uniq TLD+1 domains in certificate to include, 0 has no limit (default 80)
+        maximum number of uniq apex domains in certificate to include, 0 has no limit (default 80)
   -save string
-    	save certs to folder in PEM format
+        save certs to folder in PEM format
+  -serve string
+        address:port to serve html UI on
   -timeout uint
-    	tcp timeout in seconds (default 10)
-  -tldplus1
-    	for every domain found, add tldPlus1 of the domain's parent
+        tcp timeout in seconds (default 10)
+  -updatepsl
+        Update the default Public Suffix List
   -verbose
-    	verbose logging
+        verbose logging
   -version
-    	print version and exit
+        print version and exit
 ```
 
 ## Drivers
 
 CertGraph has multiple options for querying SSL certificates. The driver is responsible for retrieving the certificates for a given domain. Currently there are the following drivers:
 
- * **http** this is the default driver which works by connecting to the hosts over HTTPS and retrieving the certificates from the SSL connection
- 
- * **smtp** like the *http* driver, but connects over port 25 and issues the *starttls* command to retrieve the certificates from the SSL connection
+* **http** this is the default driver which works by connecting to the hosts over HTTPS and retrieving the certificates from the SSL connection
 
- * **crtsh** this driver searches Certificate Transparency logs via [crt.sh](https://crt.sh/). No packets are sent to any of the domains when using this driver
+* **smtp** like the *http* driver, but connects over port 25 and issues the *starttls* command to retrieve the certificates from the SSL connection
 
- * **google** this is another Certificate Transparency driver that behaves like *crtsh* but uses the [Google Certificate Transparency Lookup Tool](https://transparencyreport.google.com/https/certificates)
+* **crtsh** this driver searches Certificate Transparency logs via [crt.sh](https://crt.sh/). No packets are sent to any of the domains when using this driver
 
+* **google** this is another Certificate Transparency driver that behaves like *crtsh* but uses the [Google Certificate Transparency Lookup Tool](https://transparencyreport.google.com/https/certificates)
 
 ## Example
-```
+
+```console
 $ ./certgraph -details eff.org
 eff.org 0       Good    42E3E4605D8BB4608EB64936E2176A98B97EBF2E0F8F93A64A6640713C7D4325
 maps.eff.org    1       Good    42E3E4605D8BB4608EB64936E2176A98B97EBF2E0F8F93A64A6640713C7D4325
@@ -69,32 +75,54 @@ httpse-atlas.eff.org    1       Good    42E3E4605D8BB4608EB64936E2176A98B97EBF2E
 atlas.eff.org   1       Good    42E3E4605D8BB4608EB64936E2176A98B97EBF2E0F8F93A64A6640713C7D4325
 kittens.eff.org 1       Good    42E3E4605D8BB4608EB64936E2176A98B97EBF2E0F8F93A64A6640713C7D4325
 ```
+
 The above output represents the adjacency list for the graph for the root domain `eff.org`. The adjacency list is in the form:
 `Node    Depth    Status    Cert-Fingerprint`
 
 ## [Releases](https://github.com/lanrat/certgraph/releases)
 
-Precompiled releases will occasionally be uploaded to the [releases github page](https://github.com/lanrat/certgraph/releases). https://github.com/lanrat/certgraph/releases
+Pre-compiled releases will occasionally be uploaded to the [releases github page](https://github.com/lanrat/certgraph/releases). [https://github.com/lanrat/certgraph/releases](https://github.com/lanrat/certgraph/releases)
 
-Also available in [BlackArch](https://blackarch.org).
+### [Docker](https://hub.docker.com/r/lanrat/certgraph/)
+
+CertGraph is an automated build on the Docker Hub!
+
+```console
+$ docker run --rm -it lanrat/certgraph example.com
+example.com
+www.example.net
+www.example.org
+www.example.com
+example.org
+example.net
+example.edu
+www.example.edu
+```
+
+### Linux Distributions
+
+* [BlackArch](https://blackarch.org)
+* [Kali Linux](https://www.kali.org/)
 
 ## Compiling
 
-To compile certgraph you must have a working go 1.11 or newer compiler on your system, as certgraph makes use of go's modules for dependencies.
+To compile certgraph you must have a working go 1.13 or newer compiler on your system, as certgraph makes use of go's modules for dependencies.
 To compile for the running system compilation is as easy as running make
-```
+
+```console
 certgraph$ make
 go build -o certgraph certgraph.go
 ```
 
 Alternatively you can use `go get` to install with this one-liner:
-```
+
+```console
 go get -u github.com/lanrat/certgraph
 ```
 
 ## [Web UI](https://lanrat.github.io/certgraph/)
 
-A web UI is provided in the docs folder and is accessible at the github pages url [https://lanrat.github.io/certgraph/](https://lanrat.github.io/certgraph/).
+A web UI is provided in the docs folder and is accessible at the github pages url [https://lanrat.github.io/certgraph/](https://lanrat.github.io/certgraph/), or can be run from the embedded web server by calling `certgraph --serve 127.0.0.1:8080`.
 
 The web UI takes the output provided with the `-json` flag.
 The JSON graph can be sent to the web interface as an uploaded file, remote URL, or as the query string using the data variable.
@@ -111,3 +139,23 @@ The JSON graph can be sent to the web interface as an uploaded file, remote URL,
 
 [![whitehouse.gov graph](https://cloud.githubusercontent.com/assets/164192/20861407/4775ff26-b944-11e6-888c-4d93e3333494.png)](https://lanrat.github.io/certgraph/?data=https://gist.githubusercontent.com/lanrat/96c47dfee0faaaad633cc830b7e3b997/raw/3c79fed837cb3202e220de21d2a8eb128f4bbd9f/whitehouse.json)
 
+## BygoneSSL detection
+
+### Self Detection
+
+CertGraph can be used to detect [BygoneSSL](https://insecure.design) DoS with the following options. CT-DRIVER can be any Certificate Transparency capable driver.
+Provide all known input domains you own. If any domains you do not own are printed, then you are vulnerable.
+
+```console
+certgraph -depth 1 -driver CT-DRIVER -ct-subdomains -cdn -apex [DOMAIN]...
+```
+
+### Bug Bounty
+
+If you want to find a vulnerable site that has a bug bounty, certgraph can be used with the following options and any driver. But you will have better luck with a non Certificate Transparency driver to ensure that the certificates in question are actually in use
+
+```console
+certgraph -cdn -dns -apex [DOMAIN]...
+```
+
+And domains that print `* Missing DNS for` have vulnerable certificates that should be rotated.
diff --git a/certgraph.go b/certgraph.go
index f45d0ce..74c1bad 100644
--- a/certgraph.go
+++ b/certgraph.go
@@ -17,6 +17,7 @@ import (
 	"github.com/lanrat/certgraph/driver/http"
 	"github.com/lanrat/certgraph/driver/smtp"
 	"github.com/lanrat/certgraph/graph"
+	"github.com/lanrat/certgraph/web"
 )
 
 var (
@@ -28,6 +29,7 @@ var (
 var certDriver driver.Driver
 
 // config & flags
+// TODO move driver options to own struct
 var config struct {
 	timeout             time.Duration
 	verbose             bool
@@ -41,10 +43,11 @@ var config struct {
 	includeCTExpired    bool
 	cdn                 bool
 	maxSANsSize         int
-	tldPlus1            bool
+	apex                bool
 	updatePSL           bool
 	checkDNS            bool
 	printVersion        bool
+	serve               string
 }
 
 func init() {
@@ -55,16 +58,18 @@ func init() {
 	flag.StringVar(&config.driver, "driver", "http", fmt.Sprintf("driver to use [%s]", strings.Join(driver.Drivers, ", ")))
 	flag.BoolVar(&config.includeCTSubdomains, "ct-subdomains", false, "include sub-domains in certificate transparency search")
 	flag.BoolVar(&config.includeCTExpired, "ct-expired", false, "include expired certificates in certificate transparency search")
-	flag.IntVar(&config.maxSANsSize, "sanscap", 80, "maximum number of uniq TLD+1 domains in certificate to include, 0 has no limit")
+	flag.IntVar(&config.maxSANsSize, "sanscap", 80, "maximum number of uniq apex domains in certificate to include, 0 has no limit")
 	flag.BoolVar(&config.cdn, "cdn", false, "include certificates from CDNs")
 	flag.BoolVar(&config.checkDNS, "dns", false, "check for DNS records to determine if domain is registered")
-	flag.BoolVar(&config.tldPlus1, "tldplus1", false, "for every domain found, add tldPlus1 of the domain's parent")
+	flag.BoolVar(&config.apex, "apex", false, "for every domain found, add the apex domain of the domain's parent")
 	flag.BoolVar(&config.updatePSL, "updatepsl", false, "Update the default Public Suffix List")
 	flag.UintVar(&config.maxDepth, "depth", 5, "maximum BFS depth to go")
 	flag.UintVar(&config.parallel, "parallel", 10, "number of certificates to retrieve in parallel")
 	flag.BoolVar(&config.details, "details", false, "print details about the domains crawled")
 	flag.BoolVar(&config.printJSON, "json", false, "print the graph as json, can be used for graph in web UI")
 	flag.StringVar(&config.savePath, "save", "", "save certs to folder in PEM format")
+	flag.StringVar(&config.serve, "serve", "", "address:port to serve html UI on")
+
 	flag.Usage = func() {
 		fmt.Fprintf(os.Stderr, "Usage of %s: [OPTION]... HOST...\n\thttps://github.com/lanrat/certgraph\nOPTIONS:\n", os.Args[0])
 		flag.PrintDefaults()
@@ -80,6 +85,11 @@ func main() {
 		return
 	}
 
+	if len(config.serve) > 0 {
+		err := web.Serve(config.serve)
+		e(err)
+	}
+
 	// print usage if no domain passed
 	if flag.NArg() < 1 {
 		flag.Usage()
@@ -108,12 +118,12 @@ func main() {
 		d := strings.ToLower(domain)
 		if len(d) > 0 {
 			startDomains = append(startDomains, cleanInput(d))
-			if config.tldPlus1 {
-				tldPlus1, err := dns.TLDPlus1(domain)
+			if config.apex {
+				apexDomain, err := dns.ApexDomain(domain)
 				if err != nil {
 					continue
 				}
-				startDomains = append(startDomains, tldPlus1)
+				startDomains = append(startDomains, apexDomain)
 			}
 		}
 	}
@@ -148,6 +158,7 @@ func main() {
 
 // setDriver sets the driver variable for the provided driver string and does any necessary driver prep work
 // TODO make config generic and move this to driver module
+// TODO support multi-driver
 func setDriver(driver string) error {
 	var err error
 	switch driver {
@@ -160,7 +171,7 @@ func setDriver(driver string) error {
 	case "smtp":
 		certDriver, err = smtp.Driver(config.timeout, config.savePath)
 	default:
-		return fmt.Errorf("Unknown driver name: %s", config.driver)
+		return fmt.Errorf("unknown driver name: %s", config.driver)
 	}
 	return err
 }
@@ -244,13 +255,13 @@ func breathFirstSearch(roots []string) {
 					for _, neighbor := range certGraph.GetDomainNeighbors(domainNode.Domain, config.cdn, config.maxSANsSize) {
 						wg.Add(1)
 						domainNodeInputChan <- graph.NewDomainNode(neighbor, domainNode.Depth+1)
-						if config.tldPlus1 {
-							tldPlus1, err := dns.TLDPlus1(neighbor)
+						if config.apex {
+							apexDomain, err := dns.ApexDomain(neighbor)
 							if err != nil {
 								continue
 							}
 							wg.Add(1)
-							domainNodeInputChan <- graph.NewDomainNode(tldPlus1, domainNode.Depth+1)
+							domainNodeInputChan <- graph.NewDomainNode(apexDomain, domainNode.Depth+1)
 						}
 					}
 				}(domainNode)
@@ -341,7 +352,7 @@ func visit(domainNode *graph.DomainNode) {
 		domainNode.AddCertFingerprint(certNode.Fingerprint, certDriver.GetName())
 	}
 
-	// we dont process any other certificates returned, they will be collected
+	// we don't process any other certificates returned, they will be collected
 	//  when we process the related domains
 }
 
@@ -354,7 +365,7 @@ func printNode(domainNode *graph.DomainNode) {
 	if config.checkDNS && !domainNode.HasDNS {
 		// TODO print this in a better way
 		// TODO for debugging
-		realDomain, _ := dns.TLDPlus1(domainNode.Domain)
+		realDomain, _ := dns.ApexDomain(domainNode.Domain)
 		fmt.Fprintf(os.Stdout, "* Missing DNS for: %s\n", realDomain)
 
 	}
diff --git a/dns/ns.go b/dns/ns.go
index 5cd2635..0214684 100644
--- a/dns/ns.go
+++ b/dns/ns.go
@@ -1,3 +1,4 @@
+// Package dns adds utility functions for performing dns queries
 package dns
 
 import (
@@ -26,7 +27,7 @@ func noSuchHostDNSError(err error) bool {
 }
 
 // HasRecords does NS, CNAME, A, and AAAA lookups with a timeout
-// returns error when no NS found, does not use TLDPlus1
+// returns error when no NS found, does not use alexDomain
 func HasRecords(domain string, timeout time.Duration) (bool, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), timeout)
 	defer cancel()
@@ -68,10 +69,10 @@ func HasRecords(domain string, timeout time.Duration) (bool, error) {
 	return false, nil
 }
 
-// HasRecordsCache returns true if the domain has no DNS records (at the tldplus1 level)
+// HasRecordsCache returns true if the domain has no DNS records (at the apex domain level)
 // uses a cache to store results to prevent lots of DNS lookups
 func HasRecordsCache(domain string, timeout time.Duration) (bool, error) {
-	domain, err := TLDPlus1(domain)
+	domain, err := ApexDomain(domain)
 	if err != nil {
 		return false, err
 	}
diff --git a/dns/publicsuffix.go b/dns/publicsuffix.go
index 86c9ef3..6183a4e 100644
--- a/dns/publicsuffix.go
+++ b/dns/publicsuffix.go
@@ -14,7 +14,6 @@ var (
 	}
 	suffixListURL = "https://publicsuffix.org/list/public_suffix_list.dat"
 	suffixList    = publicsuffix.DefaultList
-	nsCache       = make(map[string]bool)
 )
 
 // UpdatePublicSuffixList gets a new copy of the public suffix list from the internat and updates the built in copy with the new rules
@@ -31,12 +30,12 @@ func UpdatePublicSuffixList(timeout time.Duration) error {
 	}
 	defer resp.Body.Close()
 	newSuffixList := publicsuffix.NewList()
-	newSuffixList.Load(resp.Body, suffixListParseOptions)
+	_, err = newSuffixList.Load(resp.Body, suffixListParseOptions)
 	suffixList = newSuffixList
 	return err
 }
 
-// TLDPlus1 returns TLD+1 of domain
-func TLDPlus1(domain string) (string, error) {
+// ApexDomain returns TLD+1 of domain
+func ApexDomain(domain string) (string, error) {
 	return publicsuffix.DomainFromListWithOptions(suffixList, domain, suffixListFindOptions)
 }
diff --git a/docs/index.html b/docs/index.html
index c227b54..e63ac51 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -17,7 +17,6 @@
   stroke: #333;
   stroke-width: 1.5px;
 }
-
 .upload-drop-zone {
   height: 200px;
   border-width: 2px;
diff --git a/driver/crtsh/crtsh.go b/driver/crtsh/crtsh.go
index b53c038..2974102 100644
--- a/driver/crtsh/crtsh.go
+++ b/driver/crtsh/crtsh.go
@@ -1,14 +1,12 @@
+// Package crtsh implements an unofficial API client for Comodo's
+// Certificate Transparency search
+// https://crt.sh/
+//
+// As the API is unofficial and has been reverse engineered it may stop working
+// at any time and comes with no guarantees.
+//
 package crtsh
 
-/*
- * This file implements an unofficial API client for Comodo's
- * Certificate Transparency search
- * https://crt.sh/
- *
- * As the API is unofficial and has been reverse engineered it may stop working
- * at any time and comes with no guarantees.
- */
-
 // TODO running in verbose gives error: pq: unnamed prepared statement does not exist
 
 import (
@@ -76,8 +74,11 @@ func Driver(maxQueryResults int, timeout time.Duration, savePath string, include
 	}
 
 	d.db, err = sql.Open("postgres", connStr)
+	if err != nil {
+		return nil, err
+	}
 
-	d.setSQLTimeout(d.timeout.Seconds())
+	err = d.setSQLTimeout(d.timeout.Seconds())
 
 	return d, err
 }
@@ -199,7 +200,10 @@ func (d *crtsh) QueryCert(fp fingerprint.Fingerprint) (*driver.CertResult, error
 
 	for rows.Next() {
 		var domain string
-		rows.Scan(&domain)
+		err = rows.Scan(&domain)
+		if err != nil {
+			return nil, err
+		}
 		certNode.Domains = append(certNode.Domains, domain)
 	}
 
@@ -214,7 +218,10 @@ func (d *crtsh) QueryCert(fp fingerprint.Fingerprint) (*driver.CertResult, error
 			return certNode, err
 		}
 
-		driver.RawCertToPEMFile(rawCert, path.Join(d.savePath, fp.HexString())+".pem")
+		err = driver.RawCertToPEMFile(rawCert, path.Join(d.savePath, fp.HexString())+".pem")
+		if err != nil {
+			return certNode, err
+		}
 	}
 
 	return certNode, nil
diff --git a/driver/driver.go b/driver/driver.go
index 5b4c819..0870aff 100644
--- a/driver/driver.go
+++ b/driver/driver.go
@@ -1,3 +1,4 @@
+// Package driver exposes interfaces and types certgraph drivers must implement
 package driver
 
 import (
@@ -9,6 +10,8 @@ import (
 	"github.com/lanrat/certgraph/status"
 )
 
+// TODO add context instead of timeout on all requests
+
 // Drivers contains all the drivers that have been registered
 var Drivers []string
 
diff --git a/driver/google/google.go b/driver/google/google.go
index 321e421..eef9ae8 100644
--- a/driver/google/google.go
+++ b/driver/google/google.go
@@ -1,14 +1,12 @@
+// Package google file implements an unofficial API client for Google's
+// Certificate Transparency search
+// https://transparencyreport.google.com/https/certificates
+//
+// As the API is unofficial and has been reverse engineered it may stop working
+// at any time and comes with no guarantees.
+//
 package google
 
-/*
- * This file implements an unofficial API client for Google's
- * Certificate Transparency search
- * https://transparencyreport.google.com/https/certificates
- *
- * As the API is unofficial and has been reverse engineered it may stop working
- * at any time and comes with no guarantees.
- */
-
 import (
 	"encoding/json"
 	"errors"
@@ -30,9 +28,13 @@ func init() {
 }
 
 // Base URLs for Google's CT API
-const searchURL1 = "https://transparencyreport.google.com/transparencyreport/api/v3/httpsreport/ct/certsearch?include_expired=false&include_subdomains=false&domain=example.com"
-const searchURL2 = "https://transparencyreport.google.com/transparencyreport/api/v3/httpsreport/ct/certsearch/page?p=DEADBEEF"
-const certURL = "https://transparencyreport.google.com/transparencyreport/api/v3/httpsreport/ct/certbyhash?hash=DEADBEEF"
+const (
+	searchURL1 = "https://transparencyreport.google.com/transparencyreport/api/v3/httpsreport/ct/certsearch?include_expired=false&include_subdomains=false&domain=example.com"
+	searchURL2 = "https://transparencyreport.google.com/transparencyreport/api/v3/httpsreport/ct/certsearch/page?p=DEADBEEF"
+	certURL    = "https://transparencyreport.google.com/transparencyreport/api/v3/httpsreport/ct/certbyhash?hash=DEADBEEF"
+	//summaryURL is not currently used
+	//summaryURL = "https://transparencyreport.google.com/transparencyreport/api/v3/httpsreport/ct/summary"
+)
 
 type googleCT struct {
 	maxPages          float64 // this is a float because that is the type automatically decoded from the JSON response
diff --git a/driver/http/http.go b/driver/http/http.go
index 591f566..376a3fc 100644
--- a/driver/http/http.go
+++ b/driver/http/http.go
@@ -1,3 +1,4 @@
+// Package http implements a certgraph driver for obtaining SSL certificates over https
 package http
 
 import (
@@ -53,7 +54,7 @@ func (c *httpCertDriver) QueryCert(fp fingerprint.Fingerprint) (*driver.CertResu
 	if found {
 		return cert, nil
 	}
-	return nil, fmt.Errorf("Certificate with Fingerprint %s not found", fp.HexString())
+	return nil, fmt.Errorf("certificate with Fingerprint %s not found", fp.HexString())
 }
 
 // Driver creates a new SSL driver for HTTP Connections
@@ -151,7 +152,7 @@ func (c *httpCertDriver) dialTLS(network, addr string) (net.Conn, error) {
 
 	// save
 	if c.parent.save && len(connState.PeerCertificates) > 0 {
-		driver.CertsToPEMFile(connState.PeerCertificates, path.Join(c.parent.savePath, certResult.Fingerprint.HexString())+".pem")
+		err = driver.CertsToPEMFile(connState.PeerCertificates, path.Join(c.parent.savePath, certResult.Fingerprint.HexString())+".pem")
 	}
 
 	return conn, err
diff --git a/driver/save.go b/driver/save.go
index e968aca..279a491 100644
--- a/driver/save.go
+++ b/driver/save.go
@@ -17,7 +17,10 @@ func CertsToPEMFile(certs []*x509.Certificate, file string) error {
 	}
 	defer f.Close()
 	for _, cert := range certs {
-		pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
+		err = pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})
+		if err != nil {
+			return err
+		}
 	}
 	return nil
 }
@@ -32,8 +35,8 @@ func RawCertToPEMFile(cert []byte, file string) error {
 		return err
 	}
 	defer f.Close()
-	pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: cert})
-	return nil
+	err = pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: cert})
+	return err
 }
 
 func fileExists(f string) bool {
diff --git a/driver/smtp/smtp.go b/driver/smtp/smtp.go
index 3e6ed32..cb9d908 100644
--- a/driver/smtp/smtp.go
+++ b/driver/smtp/smtp.go
@@ -1,3 +1,4 @@
+// Package smtp implements a certgraph driver for obtaining SSL certificates over smtp with STARTTLS
 package smtp
 
 import (
@@ -55,7 +56,7 @@ func (c *smtpCertDriver) QueryCert(fp fingerprint.Fingerprint) (*driver.CertResu
 	if found {
 		return cert, nil
 	}
-	return nil, fmt.Errorf("Certificate with Fingerprint %s not found", fp.HexString())
+	return nil, fmt.Errorf("certificate with Fingerprint %s not found", fp.HexString())
 }
 
 // Driver creates a new SSL driver for SMTP Connections
@@ -134,10 +135,10 @@ func (d *smtpDriver) QueryDomain(host string) (driver.Result, error) {
 
 	// save
 	if d.save && len(certs) > 0 {
-		driver.CertsToPEMFile(certs, path.Join(d.savePath, certResult.Fingerprint.HexString())+".pem")
+		err = driver.CertsToPEMFile(certs, path.Join(d.savePath, certResult.Fingerprint.HexString())+".pem")
 	}
 
-	return results, nil
+	return results, err
 }
 
 // getMX returns the MX records for the provided domain
diff --git a/fingerprint/fingerprint.go b/fingerprint/fingerprint.go
index 3bab154..ba96305 100644
--- a/fingerprint/fingerprint.go
+++ b/fingerprint/fingerprint.go
@@ -1,3 +1,4 @@
+// Package fingerprint defines types to define a certificate fingerprint for certgraph
 package fingerprint
 
 import (
@@ -17,9 +18,6 @@ func (fp *Fingerprint) HexString() string {
 // FromHashBytes returns a Fingerprint generated by the first len(Fingerprint) bytes
 func FromHashBytes(data []byte) Fingerprint {
 	var fp Fingerprint
-	/*if len(data) != sha256.Size {
-		v("Data is not correct SHA256 size", data)
-	}*/
 	for i := 0; i < len(data) && i < len(fp); i++ {
 		fp[i] = data[i]
 	}
@@ -28,17 +26,13 @@ func FromHashBytes(data []byte) Fingerprint {
 
 // FromBytes returns a Fingerprint generated by the provided bytes
 func FromBytes(data []byte) Fingerprint {
-	var fp Fingerprint
-	fp = sha256.Sum256(data)
+	fp := sha256.Sum256(data)
 	return fp
 }
 
 // FromB64 returns a Fingerprint from a base64 encoded hash string
 func FromB64(hash string) Fingerprint {
 	data, _ := base64.StdEncoding.DecodeString(hash)
-	/*if err != nil {
-		fmt.Println(err)
-	}*/
 	return FromHashBytes(data)
 }
 
diff --git a/go.mod b/go.mod
index fff6d4e..03115bc 100644
--- a/go.mod
+++ b/go.mod
@@ -1,8 +1,9 @@
 module github.com/lanrat/certgraph
 
 require (
-	github.com/lib/pq v1.0.0
-	github.com/weppos/publicsuffix-go v0.4.0
-	golang.org/x/net v0.0.0-20180906233101-161cd47e91fd // indirect
-	golang.org/x/text v0.3.0 // indirect
+	github.com/lib/pq v1.8.0
+	github.com/weppos/publicsuffix-go v0.13.0
+	golang.org/x/net v0.0.0-20200707034311-ab3426394381 // indirect
 )
+
+go 1.13
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..6f82f56
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,14 @@
+github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg=
+github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/weppos/publicsuffix-go v0.13.0 h1:0Tu1uzLBd1jPn4k6OnMmOPZH/l/9bj9kUOMMkoRs6Gg=
+github.com/weppos/publicsuffix-go v0.13.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381 h1:VXak5I6aEWmAXeQjA+QSZzlgNrpq9mjcfDemuexIKsU=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/graph/cert_node.go b/graph/cert_node.go
index 13c145e..5e5ffe7 100644
--- a/graph/cert_node.go
+++ b/graph/cert_node.go
@@ -57,17 +57,17 @@ func (c *CertNode) CDNCert() bool {
 	return false
 }
 
-// TLDPlus1Count the number of tld+1 domains in the certificate
-func (c *CertNode) TLDPlus1Count() int {
-	tldPlus1Domains := make(map[string]bool)
+// ApexCount the number of tld+1 domains in the certificate
+func (c *CertNode) ApexCount() int {
+	apexDomains := make(map[string]bool)
 	for _, domain := range c.Domains {
-		tldPlus1, err := dns.TLDPlus1(domain)
+		apexDomain, err := dns.ApexDomain(domain)
 		if err != nil {
 			continue
 		}
-		tldPlus1Domains[tldPlus1] = true
+		apexDomains[apexDomain] = true
 	}
-	return len(tldPlus1Domains)
+	return len(apexDomains)
 }
 
 // ToMap returns a map of the CertNode's fields (weak serialization)
diff --git a/graph/domain_node.go b/graph/domain_node.go
index b18d589..e1b47b4 100644
--- a/graph/domain_node.go
+++ b/graph/domain_node.go
@@ -44,7 +44,7 @@ func (d *DomainNode) AddRelatedDomains(domains []string) {
 	}
 }
 
-// CheckForDNS checks for the existence of DNS records for the domain's tld+1
+// CheckForDNS checks for the existence of DNS records for the domain's apex
 // sets the value to the node and returns the result as well
 func (d *DomainNode) CheckForDNS(timeout time.Duration) (bool, error) {
 	hasDNS, err := dns.HasRecordsCache(d.Domain, timeout)
@@ -75,7 +75,7 @@ func (d *DomainNode) GetCertificates() []fingerprint.Fingerprint {
 	return fingerprints
 }
 
-// get the string representation of a node
+// String returns the string representation of a node
 func (d *DomainNode) String() string {
 	certString := ""
 	// Certs
diff --git a/graph/graph.go b/graph/graph.go
index c519040..931464c 100644
--- a/graph/graph.go
+++ b/graph/graph.go
@@ -1,3 +1,4 @@
+// Package graph implements the graph data structures used by certgraph to build the certificate graph
 package graph
 
 import (
@@ -21,13 +22,6 @@ func NewCertGraph() *CertGraph {
 	return graph
 }
 
-// LoadOrStoreCert will return the CertNode in the graph with the provided node's fingerprint, or store the node if it did not already exist
-// returned bool is true if the CertNode was found, false if stored
-func (graph *CertGraph) LoadOrStoreCert(certNode *CertNode) (*CertNode, bool) {
-	foundCertNode, ok := graph.certs.LoadOrStore(certNode.Fingerprint, certNode)
-	return foundCertNode.(*CertNode), ok
-}
-
 // AddCert add a CertNode to the graph
 func (graph *CertGraph) AddCert(certNode *CertNode) {
 	// save the cert to the graph
@@ -97,7 +91,7 @@ func (graph *CertGraph) GetDomainNeighbors(domain string, cdn bool, maxSANsSize
 				certNode := node.(*CertNode)
 				if !cdn && certNode.CDNCert() {
 					//v(domain, "-> CDN CERT")
-				} else if maxSANsSize > 0 && certNode.TLDPlus1Count() > maxSANsSize {
+				} else if maxSANsSize > 0 && certNode.ApexCount() > maxSANsSize {
 					//v(domain, "-> Large CERT")
 				} else {
 					for _, neighbor := range certNode.Domains {
diff --git a/status/status.go b/status/status.go
index 12ec034..5e86193 100644
--- a/status/status.go
+++ b/status/status.go
@@ -1,3 +1,4 @@
+// Package status defines the various status certgraph discovered hosts/certificates may have
 package status
 
 import (
@@ -64,7 +65,7 @@ const (
 	CT       = iota
 )
 
-// return domain status for printing
+// String returns the domain status for printing
 func (status DomainStatus) String() string {
 	switch status {
 	case UNKNOWN:
diff --git a/web/generate.sh b/web/generate.sh
new file mode 100755
index 0000000..f6cc5cc
--- /dev/null
+++ b/web/generate.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+set -e
+HTMLFILE="../docs/index.html"
+HTML="$(cat $HTMLFILE)"
+DATE="$(date)"
+
+cat > index_html.go <<EOL
+package web
+
+// Code generated on "$DATE" DO NOT EDIT.
+
+const indexSource = \`$HTML\`
+
+EOL
\ No newline at end of file
diff --git a/web/index_html.go b/web/index_html.go
new file mode 100644
index 0000000..32ec8db
--- /dev/null
+++ b/web/index_html.go
@@ -0,0 +1,527 @@
+package web
+
+// Code generated on "Mon Aug  3 14:01:58 PDT 2020" DO NOT EDIT.
+
+const indexSource = `<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CertGraph</title>
+<link href="https://maxcdn.bootstrapcdn.com/bootswatch/3.3.7/cerulean/bootstrap.min.css" rel="stylesheet" integrity="sha384-zF4BRsG/fLiTGfR9QL82DrilZxrwgY/+du4p/c7J72zZj+FLYq4zY00RylP9ZjiT" crossorigin="anonymous">
+<script src="https://d3js.org/d3.v4.min.js"></script>
+<style type="text/css">
+.links line {
+  stroke-opacity: 0.6;
+  stroke-width: 1px;
+  fill: none;
+}
+.nodes circle {
+  stroke: #333;
+  stroke-width: 1.5px;
+}
+.upload-drop-zone {
+  height: 200px;
+  border-width: 2px;
+  margin-bottom: 20px;
+  color: #ccc;
+  border-style: dashed;
+  border-color: #ccc;
+  line-height: 200px;
+  text-align: center
+}
+.upload-drop-zone.drop {
+  color: #222;
+  border-color: #222;
+}
+</style>
+</head>
+<body>
+<div class="container">
+
+<nav class="navbar navbar-inverse">
+    <div class="container-fluid">
+        <div class="navbar-header">
+            <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
+                <span class="sr-only">Toggle navigation</span>
+                <span class="icon-bar"></span>
+                <span class="icon-bar"></span>
+                <span class="icon-bar"></span>
+            </button>
+            <a class="navbar-brand" href="#">CertGraph</a>
+        </div>
+        <div id="navbar" class="navbar-collapse collapse">
+            <ul class="nav navbar-nav">
+                <!-- <li><a href="#">Graph</a></li> -->
+            </ul>
+            <ul class="nav navbar-nav navbar-right">
+                 <li class="dropdown">
+          <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">Data <span class="caret"></span></a>
+          <ul class="dropdown-menu" role="menu">
+            <li><a href="#" data-toggle="modal" data-target="#URLmodal">URL</a></li>
+            <li><a href="#" data-toggle="modal" data-target="#Pastemodal">Paste</a></li>
+            <li><a href="#" data-toggle="modal" data-target="#Filemodal">File Upload</a></li>
+          </ul>
+        </li>
+            </ul>
+        </div>
+    </div>
+</nav>
+
+<div class="panel panel-info">
+  <div class="panel-heading">
+    <h3 class="panel-title pull-left">Graph</h3>
+    <div class="pull-right"><a href="#" class="btn btn-primary btn-xs" id="generate">Download SVG</a></div>
+    <div class="clearfix"></div>
+  </div>
+    <svg id="graph" width="100%" height="500"></svg>
+</div>
+
+<div class="panel panel-info">
+  <div class="panel-heading">Info</div>
+  <div class="panel-body" id="node-info">
+  </div>
+</div>
+
+<ul class="nav nav-tabs">
+  <li class="active"><a href="#domains" data-toggle="tab" aria-expanded="false">Domains</a></li>
+  <li class=""><a href="#certificates" data-toggle="tab" aria-expanded="true">Certificates</a></li>
+</ul>
+<div id="myTabContent" class="tab-content panel-body">
+  <div class="tab-pane fade active in" id="domains">
+    
+<table class="table table-striped table-hover ">
+  <thead>
+    <tr>
+      <th>#</th>
+      <th>Domain</th>
+      <th>Status</th>
+      <th>Lookup</th>
+    </tr>
+  </thead>
+  <tbody id="domain-list">
+  </tbody>
+</table> 
+
+  </div>
+  <div class="tab-pane fade" id="certificates">
+    
+<table class="table table-striped table-hover ">
+  <thead>
+    <tr>
+      <th>#</th>
+      <th>Hash</th>
+      <th>Lookup</th>
+    </tr>
+  </thead>
+  <tbody id="cert-list">
+  </tbody>
+</table> 
+
+  </div>
+
+</div>
+
+<footer>
+<hr>
+<div class="row">
+    <div class="col-xs-10"><a href="https://github.com/lanrat/certgraph">CertGraph</a></div>
+</div>
+</footer>
+
+<!-- URL Modal -->
+<div class="modal fade" id="URLmodal" role="dialog">
+  <div class="modal-dialog">
+    <!-- Modal content-->
+    <div class="modal-content">
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal">&times;</button>
+        <h4 class="modal-title">Data URL</h4>
+      </div>
+      <div class="modal-body">
+        <label for="inputURL" class="col-lg-2 control-label">URL</label>
+        <div class="col-lg-10">
+          <input type="text" class="form-control" id="inputURL" placeholder="https://domain.com/data.json">
+        </div>
+      </div>
+      <div class="modal-footer">
+        <button type="button" class="btn btn-primary" data-dismiss="modal" id="loadURL">Load</button>
+        <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+      </div>
+    </div>
+  </div>
+</div> <!-- /URL Modal -->
+
+<!-- Paste Modal -->
+<div class="modal fade" id="Pastemodal" role="dialog">
+  <div class="modal-dialog">
+    <!-- Modal content-->
+    <div class="modal-content">
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal">&times;</button>
+        <h4 class="modal-title">JSON Data</h4>
+      </div>
+      <div class="modal-body">
+        <label for="inputPaste" class="col-lg-2 control-label">URL</label>
+        <div class="col-lg-10">
+          <textarea class="form-control" rows="10" id="inputPaste"></textarea>
+        </div>
+      </div>
+      <div class="modal-footer">
+        <button type="button" class="btn btn-primary" data-dismiss="modal" id="loadPaste">Load</button>
+        <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+      </div>
+    </div>
+  </div>
+</div> <!-- /Paste Modal -->
+
+<!-- File Modal -->
+<div class="modal fade" id="Filemodal" role="dialog">
+  <div class="modal-dialog">
+    <!-- Modal content-->
+    <div class="modal-content">
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal">&times;</button>
+        <h4 class="modal-title">JSON File</h4>
+      </div>
+      <div class="modal-body">
+        <label for="inputFile" class="col-lg-2 control-label">File</label>
+        <div class="col-lg-10">
+         <input type="file" class="form-control file" id="inputFile">
+        </div>
+        <br/>
+        <label for="drop-zone" class="col-lg-2 control-label">Or drag and drop a file below</label>
+        <div class="upload-drop-zone" id="drop-zone">
+          Just drag and drop a JSON file here
+        </div>
+      </div>
+      <div class="modal-footer">
+        <button type="button" class="btn btn-primary" data-dismiss="modal" id="loadFile">Load</button>
+        <button type="button" class="btn btn-default" data-dismiss="modal" id="fileClose">Close</button>
+      </div>
+    </div>
+  </div>
+</div> <!-- /File Modal -->
+
+</div> <!-- /container-->
+<script src="//code.jquery.com/jquery-1.11.3.min.js"></script>
+<script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.4/js/bootstrap.min.js"></script>
+<script src="//cdn.rawgit.com/eligrey/FileSaver.js/e9d941381475b5df8b7d7691013401e171014e89/FileSaver.min.js"></script>
+<script>
+var svg = d3.select("svg");
+var width = window.innerWidth-100;
+//var width = svg.attr("width");
+var height = svg.attr("height");
+
+/*var svgElem = document.getElementById("graph");
+var width = svgElem.width.animVal.value,
+    height = svgElem.height.animVal.value;
+console.log(width, height);*/
+
+// TODO THIS
+// http://www.coppelia.io/2014/07/an-a-to-z-of-extra-features-for-the-d3-force-layout/
+
+var color = d3.scaleOrdinal(d3.schemeCategory10);
+var simulation;
+
+svg = svg.call(d3.zoom().on("zoom", zoomed)).append("g");
+
+svg.append("defs").append("marker")
+  .attr("id", "arrow")
+  .attr("viewBox", "0 -5 10 10")
+  .attr("refX", 20)
+  .attr("refY", 0)
+  .attr("markerWidth", 8)
+  .attr("markerHeight", 8)
+  .attr("orient", "auto")
+  //.attr("stroke", function(d) { return color(d.type); })
+  .append("svg:path")
+  .attr("d", "M0,-5L10,0L0,5");
+
+function resetGraph() {
+  d3.select("g").selectAll("*").remove();
+  createTables();
+
+  // reset info
+  var el = document.getElementById("node-info");
+  el.innerText = "Click on a node in the graph to view details.";
+
+  // redo layout
+  simulation = d3.forceSimulation()
+    .force("link", d3.forceLink().id(function(d) { return d.id; }))
+    .force("charge", d3.forceManyBody().strength(-100))
+    .force("center", d3.forceCenter(width / 2, height / 2));
+}
+
+function createGraph (error, graph) {
+  if (error) throw error;
+
+  var link = svg.append("g")
+      .attr("class", "links")
+    .selectAll("line")
+    .data(graph.links)
+    .enter().append("line")
+      .attr("stroke", function(d) { return color(d.type); })
+       .attr("marker-end", "url(#arrow)");
+
+  var text = svg.append("g").attr("class", "labels").selectAll("g")
+    .data(graph.nodes)
+  .enter().append("g");
+
+  text.append("text")
+    .attr("x", 14)
+    .attr("y", ".31em")
+    .style("font-family", "sans-serif")
+    .style("font-size", "0.7em")
+    .text(function(d) { if (d.type == "domain") {return d.id; } return d.id.substring(0,8); });
+
+  var node = svg.append("g")
+      .attr("class", "nodes")
+    .selectAll("circle")
+    .data(graph.nodes)
+    .enter().append("circle")
+      .attr("r", 10)
+      .attr("fill", function(d) { if (d.root == "true") return color(d.root); return color(d.type); })
+      .call(d3.drag()
+          .on("start", dragstarted)
+          .on("drag", dragged)
+          .on("end", dragended));
+
+  node.on("click",function(d){
+    // console.log("clicked", d.id);
+    // console.log(d);
+    updateInfoBox(d);
+  });
+
+  node.append("title")
+      .text(function(d) { return d.id; });
+
+  simulation
+      .nodes(graph.nodes)
+      .on("tick", ticked);
+
+  simulation.force("link")
+      .links(graph.links);
+
+  function ticked() {
+    link
+        .attr("x1", function(d) { return d.source.x; })
+        .attr("y1", function(d) { return d.source.y; })
+        .attr("x2", function(d) { return d.target.x; })
+        .attr("y2", function(d) { return d.target.y; });
+
+    node
+        .attr("cx", function(d) { return d.x; })
+        .attr("cy", function(d) { return d.y; });
+    text
+        .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"});
+  }
+  createTables();
+}
+
+
+function dragstarted(d) {
+  if (!d3.event.active) simulation.alphaTarget(0.3).restart();
+  d.fx = d.x;
+  d.fy = d.y;
+}
+
+function dragged(d) {
+  d.fx = d3.event.x;
+  d.fy = d3.event.y;
+}
+
+function dragended(d) {
+  if (!d3.event.active) simulation.alphaTarget(0);
+  d.fx = null;
+  d.fy = null;
+}
+
+function zoomed() {
+  svg.attr("transform", "translate(" + d3.event.transform.x + "," + d3.event.transform.y + ")" + " scale(" + d3.event.transform.k + ")");
+}
+
+d3.select("#generate").on("click", writeDownloadLink);
+function writeDownloadLink(){
+    try {
+        var isFileSaverSupported = !!new Blob();
+    } catch (e) {
+        alert("blob not supported");
+    }
+
+    var html = d3.select("svg")
+        .attr("title", "graph") //TODO
+        .attr("version", 1.1)
+        .attr("xmlns", "http://www.w3.org/2000/svg")
+        .node().outerHTML;
+
+    var blob = new Blob([html], {type: "image/svg+xml"});
+    saveAs(blob, "certificate_graph.svg"); //TODO root node name
+};
+
+function updateInfoBox(d) {
+  if (d) {
+    var el = document.getElementById("node-info");
+    var s = "Type: "+d.type+"</br>";
+    if (d.type == "domain") {
+      s = s + "Domain: "+linkifyDomain(d)+"</br>";
+      s = s + "Status: "+d.status+"</br>";
+    }else if (d.type = "certificate") {
+      s = s + "Hash: "+linkifyCert(d)+"</br>";
+    }
+    el.innerHTML = s;
+  }
+}
+
+function createTables() {
+  // TODO: redo this in native d3
+  domainEl = document.getElementById("domain-list");
+  domain_tbody2 = document.createElement('tbody');
+  domain_tbody2.id="domain-list";
+  domainEl.parentNode.replaceChild(domain_tbody2, domainEl);
+
+  certEl = document.getElementById("cert-list");
+  cert_tbody2 = document.createElement('tbody');
+  cert_tbody2.id="cert-list";
+  certEl.parentNode.replaceChild(cert_tbody2, certEl);
+
+  var domainCount = 0;
+  function addTableDomain(d) {
+    //console.log("domain", d);
+    var c = "";
+    if (d.root == "true") {
+      c = "info";
+    }
+    $('#domain-list').append('<tr class="'+c+'"><td>'+ ++domainCount +'</td><td>'+linkifyDomain(d)+'</td><td>'+d.status+'</td><td>'+linkifyAny(d)+'</td></tr>');
+  }
+
+  var certCount = 0;
+  function addTableCert(d) {
+    //console.log("cert", d);
+    $('#cert-list').append('<tr><td>'+ ++certCount + '</td><td>'+linkifyCert(d)+'</td><td>'+linkifyAny(d)+'</td></tr>');
+  }
+
+
+  d3.selectAll('circle').each(function(d){
+    if (d.type == "domain") {
+      addTableDomain(d);
+    }else if (d.type == "certificate") {
+      addTableCert(d);
+    } else {
+      console.log("Unknown Type: ", d.type);
+    }
+  })
+}
+
+function linkifyCert(d) {
+  return '<a target="_blank" href="https://crt.sh/?sha256='+d.id+'">'+d.id+'</a>';
+}
+function linkifyDomain(d) {
+  return '<a target="_blank" href="https://'+d.id+'">'+d.id+'</a>';
+}
+function linkifyAny(d) {
+  return '<a target="_blank" href="https://crt.sh/?q='+d.id+'">&#x1F50E;</a>';
+}
+
+function getQueryVariable(variable){
+  var query = window.location.search.substring(1);
+  var vars = query.split("&");
+  for (var i=0;i<vars.length;i++) {
+    var pair = vars[i].split("=");
+    if(pair[0] == variable){return pair[1];}
+  }
+  return "";
+}
+
+// new data from url
+d3.select("#loadURL").on("click", loadURL);
+function loadURL(){
+  var url = document.getElementById("inputURL").value;
+  history.pushState('', 'CertGraph', "?data="+url);
+  resetGraph();
+  d3.json(url, createGraph); 
+}
+
+// new data from paste
+d3.select("#loadPaste").on("click", loadPaste);
+function loadPaste(){
+  var dataStr = document.getElementById("inputPaste").value;
+  history.pushState('', 'CertGraph', "?");
+  resetGraph();
+  var data = JSON.parse(dataStr);
+  createGraph(null, data);
+}
+
+// new data from paste
+d3.select("#loadFile").on("click", loadFile);
+function loadFile(){
+  var file = document.getElementById("inputFile").files[0];
+  history.pushState('', 'CertGraph', "?");
+  resetGraph();
+  var reader = new FileReader();
+  reader.onload = function(e) {
+    var dataStr = reader.result;
+    var data = JSON.parse(dataStr);
+    createGraph(null, data);
+  }
+  reader.readAsText(file);
+}
+
+var dropbox = document.getElementById('drop-zone');
+function dragenter(e) {
+  e.stopPropagation();
+  e.preventDefault();
+  dropbox.className = 'upload-drop-zone drop';
+  // console.log("enter");
+  return false;
+}
+function dragover(e) {
+  e.stopPropagation();
+  e.preventDefault();
+  // console.log("over");
+}
+function dragleave(e) {
+  e.stopPropagation();
+  e.preventDefault();
+  dropbox.className = 'upload-drop-zone';
+  // console.log("leave");
+  return false;
+}
+function drop(e) {
+  // console.log("drop");
+  e.stopPropagation();
+  e.preventDefault();
+  dropbox.className = 'upload-drop-zone';
+
+  var dt = e.dataTransfer;
+  var files = dt.files;
+
+  var reader = new FileReader();
+  reader.onload = function(e) {
+    var dataStr = reader.result;
+    var data = JSON.parse(dataStr);
+    resetGraph();
+    createGraph(null, data);
+  }
+  reader.readAsText(files[0]);
+  $('#fileClose').click();
+  return false;
+}
+dropbox.addEventListener("dragenter", dragenter, false);
+dropbox.addEventListener("dragover", dragover, false);
+dropbox.addEventListener("drop", drop, false);
+dropbox.addEventListener("dragleave",dragleave, false);
+
+// load initial graph data
+var dataURL = getQueryVariable("data");
+if (dataURL == "") {
+  // default graph
+  dataURL = "https://gist.githubusercontent.com/lanrat/8187d01793bf3e578d76495182654206/raw/c49741b5206d81935febdf563452cc4346381e52/eff.json";
+}
+resetGraph();
+d3.json(dataURL, createGraph); 
+</script>
+</body>
+</html>`
+
diff --git a/web/web.go b/web/web.go
new file mode 100644
index 0000000..7f7c74d
--- /dev/null
+++ b/web/web.go
@@ -0,0 +1,19 @@
+// Package web defines a minimal web server for serving the web UI
+package web
+
+import (
+	"fmt"
+	"net/http"
+)
+
+//go:generate ./generate.sh
+
+// Serve starts a very basic webserver serving the embed web UI
+func Serve(addr string) error {
+	http.HandleFunc("/", indexHandler)
+	return http.ListenAndServe(addr, nil)
+}
+
+func indexHandler(w http.ResponseWriter, r *http.Request) {
+	fmt.Fprintf(w, "%s", indexSource)
+}