Repository: bpicode/fritzctl
Pull request #118 (2017-12-17 14:54:05)

new command "certificate export" to dump the x509 certificate offered…
… by the FRITZ!Box in PEM format

codecov[bot]: # [Codecov](https://codecov.io/gh/bpicode/fritzctl/pull/118?src=pr&el=h1) Report
> Merging [#118](https://codecov.io/gh/bpicode/fritzctl/pull/118?src=pr&el=desc) into [master](https://codecov.io/gh/bpicode/fritzctl/commit/ce9bd85cb50bf586662041ecba6626b399783157?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/bpicode/fritzctl/pull/118/graphs/tree.svg?src=pr&token=BE6YV2nOnc&width=650&height=150)](https://codecov.io/gh/bpicode/fritzctl/pull/118?src=pr&el=tree)

```diff
@@          Coverage Diff          @@
##           master   #118   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files          74     76    +2     
  Lines        1816   1848   +32     
=====================================
+ Hits         1816   1848   +32
```


| [Impacted Files](https://codecov.io/gh/bpicode/fritzctl/pull/118?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [cmd/certificate.go](https://codecov.io/gh/bpicode/fritzctl/pull/118/diff?src=pr&el=tree#diff-Y21kL2NlcnRpZmljYXRlLmdv) | `100% <100%> (ø)` | |
| [cmd/certificate\_export.go](https://codecov.io/gh/bpicode/fritzctl/pull/118/diff?src=pr&el=tree#diff-Y21kL2NlcnRpZmljYXRlX2V4cG9ydC5nbw==) | `100% <100%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/bpicode/fritzctl/pull/118?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/bpicode/fritzctl/pull/118?src=pr&el=footer). Last update [ce9bd85...ca6278a](https://codecov.io/gh/bpicode/fritzctl/pull/118?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/cmd/certificate.go b/cmd/certificate.go
new file mode 100644
index 0000000..b684921
--- /dev/null
+++ b/cmd/certificate.go
@@ -0,0 +1,15 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+)
+
+var certCmd = &cobra.Command{
+	Use:   "certificate",
+	Short: "See subcommands",
+	Long:  "See subcommands. Run with --help to list the available commands.",
+}
+
+func init() {
+	RootCmd.AddCommand(certCmd)
+}
diff --git a/cmd/certificate_export.go b/cmd/certificate_export.go
new file mode 100644
index 0000000..43be1bc
--- /dev/null
+++ b/cmd/certificate_export.go
@@ -0,0 +1,100 @@
+package cmd
+
+import (
+	"crypto/sha256"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/hex"
+	"encoding/pem"
+	"fmt"
+	"io"
+	"os"
+
+	"github.com/bpicode/fritzctl/config"
+	"github.com/bpicode/fritzctl/console"
+	"github.com/bpicode/fritzctl/stringutils"
+	"github.com/spf13/cobra"
+)
+
+var certExportCmd = &cobra.Command{
+	Use:   "export",
+	Short: "Export the TLS certificate offered by the FRITZ!Box",
+	Long: `Export TLS certificate offered by the FRITZ!Box to stdout, encoded in PEM format.
+The output can be redirected to a file or copied from the terminal output.
+Use the data beginning with "-----BEGIN CERTIFICATE-----" and ending with "-----END CERTIFICATE-----".`,
+	Example: "fritzctl certificate export > /path/to/certificate.pem",
+	RunE:    certExport,
+}
+
+func init() {
+	certCmd.AddCommand(certExportCmd)
+}
+
+func certExport(_ *cobra.Command, _ []string) error {
+	cfg := mustReadConfig()
+	conn := mustConnect(cfg)
+	defer conn.Close()
+	crt := mustHaveCert(conn)
+	logCrt(&printableCert{crt}, os.Stderr)
+	writeCrt(crt, os.Stdout)
+	return nil
+}
+
+func mustReadConfig() *config.Config {
+	path, err := config.FindConfigFile()
+	assertNoErr(err, "cannot find configuration file")
+	cfg, err := config.New(path)
+	assertNoErr(err, "cannot parse configuration file")
+	return cfg
+}
+
+func mustConnect(cfg *config.Config) *tls.Conn {
+	conn, err := tls.Dial("tcp", connectionString(cfg), &tls.Config{InsecureSkipVerify: true})
+	assertNoErr(err, "certificate export failed, cannot connect to remote")
+	return conn
+}
+
+func mustHaveCert(conn *tls.Conn) *x509.Certificate {
+	state := conn.ConnectionState()
+	assertTrue(len(state.PeerCertificates) > 0, "certificate export failed, list of peer certificates is empty")
+	crt := state.PeerCertificates[len(state.PeerCertificates)-1]
+	return crt
+}
+
+func writeCrt(crt *x509.Certificate, w io.Writer) {
+	p := pem.Block{Type: "CERTIFICATE", Bytes: crt.Raw}
+	pem.Encode(w, &p)
+}
+
+func logCrt(crt *printableCert, w io.Writer) {
+	fmt.Fprintf(w, "%s %s\n", console.Cyan("Serial Number:      "), crt.serial())
+	fmt.Fprintf(w, "%s %s\n", console.Cyan("Issued to:          "), crt.issuedTo())
+	fmt.Fprintf(w, "%s %s\n", console.Cyan("Validity:           "), crt.validity())
+	fmt.Fprintf(w, "%s %s\n", console.Cyan("SHA-256 fingerprint:"), crt.fingerprintSHA256())
+}
+
+func connectionString(cfg *config.Config) string {
+	return fmt.Sprintf("%s:%s", cfg.Host, stringutils.DefaultIfEmpty(cfg.Port, "443"))
+}
+
+type printableCert struct {
+	*x509.Certificate
+}
+
+func (c *printableCert) serial() string {
+	sn := c.SerialNumber.Bytes
+	return hex.EncodeToString(sn())
+}
+
+func (c *printableCert) fingerprintSHA256() string {
+	sum256 := sha256.Sum256(c.Raw)
+	return hex.EncodeToString(sum256[:])
+}
+
+func (c *printableCert) issuedTo() string {
+	return fmt.Sprintf("CN='%s' O='%s'", c.Subject.CommonName, c.Subject.Organization)
+}
+
+func (c *printableCert) validity() string {
+	return fmt.Sprintf("from %s to %s", c.NotBefore, c.NotAfter)
+}


Test patch:
diff --git a/cmd/certificate_export_test.go b/cmd/certificate_export_test.go
new file mode 100644
index 0000000..0c24eea
--- /dev/null
+++ b/cmd/certificate_export_test.go
@@ -0,0 +1,25 @@
+package cmd
+
+import (
+	"net"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/bpicode/fritzctl/config"
+	"github.com/stretchr/testify/assert"
+)
+
+// Test_certExport test the certificate export.
+func Test_certExport(t *testing.T) {
+	config.Dir = "../testdata/config"
+	config.Filename = "config_localhost_https_test.json"
+	assertions := assert.New(t)
+	server := httptest.NewUnstartedServer(nil)
+	var err error
+	server.Listener, err = net.Listen("tcp", ":61666")
+	assertions.NoError(err)
+	server.StartTLS()
+	defer server.Close()
+	err = certExportCmd.RunE(certExportCmd, []string{})
+	assertions.NoError(err)
+}
diff --git a/cmd/client_test.go b/cmd/client_test.go
index 6f45d5f..ada23b3 100644
--- a/cmd/client_test.go
+++ b/cmd/client_test.go
@@ -20,7 +20,7 @@ func TestConfigFileCannotBeDetermined(t *testing.T) {
 // TestConfigFiles walks through several config files and pipes them through the option determination.
 func TestConfigFiles(t *testing.T) {
 	for i, path := range []string{
-		"../testdata/config/config_localhost_https_test.json",
+		"../testdata/config/config_localhost_http_test.json",
 		"../testdata/config/config_skip_tls.json",
 		"../testdata/config/config_with_cert.json",
 	} {
diff --git a/cmd/commands_test.go b/cmd/commands_test.go
index 7403a19..412e9fd 100644
--- a/cmd/commands_test.go
+++ b/cmd/commands_test.go
@@ -15,7 +15,7 @@ import (
 // TestCommands is a unit test that runs most commands.
 func TestCommands(t *testing.T) {
 	config.Dir = "../testdata/config"
-	config.Filename = "config_localhost_https_test.json"
+	config.Filename = "config_localhost_http_test.json"
 
 	testCases := []struct {
 		cmd  *cobra.Command
@@ -44,9 +44,9 @@ func TestCommands(t *testing.T) {
 	}
 	for _, testCase := range testCases {
 		t.Run(fmt.Sprintf("Test run command %s", testCase.cmd.Name()), func(t *testing.T) {
-			l, err := net.Listen("tcp", ":61666")
+			var err error
+			testCase.srv.Listener, err = net.Listen("tcp", ":61666")
 			assert.NoError(t, err)
-			testCase.srv.Listener = l
 			testCase.srv.Start()
 			defer testCase.srv.Close()
 			err = testCase.cmd.RunE(testCase.cmd, testCase.args)
diff --git a/cmd/list_inetstats_test.go b/cmd/list_inetstats_test.go
index 09c1364..bb6efd6 100644
--- a/cmd/list_inetstats_test.go
+++ b/cmd/list_inetstats_test.go
@@ -12,7 +12,7 @@ import (
 // TestListInetStats  tests the command.
 func TestListInetStats(t *testing.T) {
 	config.Dir = "../testdata/config"
-	config.Filename = "config_localhost_https_test.json"
+	config.Filename = "config_localhost_http_test.json"
 	srv := mock.New().UnstartedServer()
 	l, err := net.Listen("tcp", ":61666")
 	assert.NoError(t, err)
diff --git a/testdata/config/config_localhost_http_test.json b/testdata/config/config_localhost_http_test.json
new file mode 100644
index 0000000..8d2e905
--- /dev/null
+++ b/testdata/config/config_localhost_http_test.json
@@ -0,0 +1,7 @@
+{
+  "protocol": "http",
+  "host": "localhost:61666",
+  "loginURL": "/login_sid.lua",
+  "username": "",
+  "password": "xxxxx"
+}
diff --git a/testdata/config/config_localhost_https_test.json b/testdata/config/config_localhost_https_test.json
index 8d2e905..57c2fd1 100644
--- a/testdata/config/config_localhost_https_test.json
+++ b/testdata/config/config_localhost_https_test.json
@@ -1,6 +1,7 @@
 {
-  "protocol": "http",
-  "host": "localhost:61666",
+  "protocol": "https",
+  "host": "localhost",
+  "port": "61666",
   "loginURL": "/login_sid.lua",
   "username": "",
   "password": "xxxxx"