From f289f4b1b6f91f38d99ac8b9e3f9bdc9536609e7 Mon Sep 17 00:00:00 2001
From: Ainar Garipov <a.garipov@adguard.com>
Date: Thu, 12 May 2022 17:41:39 +0300
Subject: [PATCH] Pull request: websvc: add system info

Merge in DNS/adguard-home from websvc-system-info to master

Squashed commit of the following:

commit 333aaa0602da254e25e0262a10080bf44a3718a7
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Thu May 12 16:32:32 2022 +0300

    websvc: fmt

commit d8a35bf71dcc59fdd595494e5b220e3d24516728
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Thu May 12 16:10:11 2022 +0300

    websvc: refactor, imp tests

commit dfeb24f3f35513bf51323d3ab6f717f582a1defc
Author: Ainar Garipov <A.Garipov@AdGuard.COM>
Date:   Wed May 11 20:52:02 2022 +0300

    websvc: add system info
---
 go.mod                            |  1 +
 go.sum                            |  2 +
 internal/v1/cmd/cmd.go            |  4 +-
 internal/v1/websvc/json.go        | 61 +++++++++++++++++++++++++++++++
 internal/v1/websvc/middleware.go  | 16 ++++++++
 internal/v1/websvc/path.go        |  8 ++++
 internal/v1/websvc/system.go      | 35 ++++++++++++++++++
 internal/v1/websvc/system_test.go | 36 ++++++++++++++++++
 internal/v1/websvc/websvc.go      | 46 ++++++++++++++++++++++-
 internal/v1/websvc/websvc_test.go | 52 ++++++++++++++++++++------
 openapi/v1.yaml                   | 16 +++++++-
 11 files changed, 260 insertions(+), 17 deletions(-)
 create mode 100644 internal/v1/websvc/json.go
 create mode 100644 internal/v1/websvc/middleware.go
 create mode 100644 internal/v1/websvc/path.go
 create mode 100644 internal/v1/websvc/system.go
 create mode 100644 internal/v1/websvc/system_test.go

diff --git a/go.mod b/go.mod
index 3b64e7db..8da34653 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
 	github.com/NYTimes/gziphandler v1.1.1
 	github.com/ameshkov/dnscrypt/v2 v2.2.3
 	github.com/digineo/go-ipset/v2 v2.2.1
+	github.com/dimfeld/httptreemux/v5 v5.4.0
 	github.com/fsnotify/fsnotify v1.5.4
 	github.com/go-ping/ping v0.0.0-20211130115550-779d1e919534
 	github.com/google/go-cmp v0.5.7
diff --git a/go.sum b/go.sum
index 06ae9b96..807a6849 100644
--- a/go.sum
+++ b/go.sum
@@ -52,6 +52,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/digineo/go-ipset/v2 v2.2.1 h1:k6skY+0fMqeUjjeWO/m5OuWPSZUAn7AucHMnQ1MX77g=
 github.com/digineo/go-ipset/v2 v2.2.1/go.mod h1:wBsNzJlZlABHUITkesrggFnZQtgW5wkqw1uo8Qxe0VU=
+github.com/dimfeld/httptreemux/v5 v5.4.0 h1:IiHYEjh+A7pYbhWyjmGnj5HZK6gpOOvyBXCJ+BE8/Gs=
+github.com/dimfeld/httptreemux/v5 v5.4.0/go.mod h1:QeEylH57C0v3VO0tkKraVz9oD3Uu93CKPnTLbsidvSw=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/fanliao/go-promise v0.0.0-20141029170127-1890db352a72/go.mod h1:PjfxuH4FZdUyfMdtBio2lsRr1AKEaVPwelzuHuh8Lqc=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
diff --git a/internal/v1/cmd/cmd.go b/internal/v1/cmd/cmd.go
index 4c4e252f..1f1cc64e 100644
--- a/internal/v1/cmd/cmd.go
+++ b/internal/v1/cmd/cmd.go
@@ -20,7 +20,8 @@ import (
 func Main(clientBuildFS fs.FS) {
 	// # Initial Configuration
 
-	rand.Seed(time.Now().UnixNano())
+	start := time.Now()
+	rand.Seed(start.UnixNano())
 
 	// TODO(a.garipov): Set up logging.
 
@@ -35,6 +36,7 @@ func Main(clientBuildFS fs.FS) {
 			IP:   net.IP{127, 0, 0, 1},
 			Port: 3001,
 		}},
+		Start:   start,
 		Timeout: 60 * time.Second,
 	})
 
diff --git a/internal/v1/websvc/json.go b/internal/v1/websvc/json.go
new file mode 100644
index 00000000..beb7f7ec
--- /dev/null
+++ b/internal/v1/websvc/json.go
@@ -0,0 +1,61 @@
+package websvc
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/AdguardTeam/golibs/log"
+)
+
+// JSON Utilities
+
+// jsonTime is a time.Time that can be decoded from JSON and encoded into JSON
+// according to our API conventions.
+type jsonTime time.Time
+
+// type check
+var _ json.Marshaler = jsonTime{}
+
+// nsecPerMsec is the number of nanoseconds in a millisecond.
+const nsecPerMsec = float64(time.Millisecond / time.Nanosecond)
+
+// MarshalJSON implements the json.Marshaler interface for jsonTime.  err is
+// always nil.
+func (t jsonTime) MarshalJSON() (b []byte, err error) {
+	msec := float64(time.Time(t).UnixNano()) / nsecPerMsec
+	b = strconv.AppendFloat(nil, msec, 'f', 3, 64)
+
+	return b, nil
+}
+
+// type check
+var _ json.Unmarshaler = (*jsonTime)(nil)
+
+// UnmarshalJSON implements the json.Marshaler interface for *jsonTime.
+func (t *jsonTime) UnmarshalJSON(b []byte) (err error) {
+	if t == nil {
+		return fmt.Errorf("json time is nil")
+	}
+
+	msec, err := strconv.ParseFloat(string(b), 64)
+	if err != nil {
+		return fmt.Errorf("parsing json time: %w", err)
+	}
+
+	*t = jsonTime(time.Unix(0, int64(msec*nsecPerMsec)).UTC())
+
+	return nil
+}
+
+// writeJSONResponse encodes v into w and logs any errors it encounters.  r is
+// used to get additional information from the request.
+func writeJSONResponse(w io.Writer, r *http.Request, v interface{}) {
+	err := json.NewEncoder(w).Encode(v)
+	if err != nil {
+		log.Error("websvc: writing resp to %s %s: %s", r.Method, r.URL.Path, err)
+	}
+}
diff --git a/internal/v1/websvc/middleware.go b/internal/v1/websvc/middleware.go
new file mode 100644
index 00000000..c87c57d5
--- /dev/null
+++ b/internal/v1/websvc/middleware.go
@@ -0,0 +1,16 @@
+package websvc
+
+import "net/http"
+
+// Middlewares
+
+// jsonMw sets the content type of the response to application/json.
+func jsonMw(h http.Handler) (wrapped http.HandlerFunc) {
+	f := func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+
+		h.ServeHTTP(w, r)
+	}
+
+	return http.HandlerFunc(f)
+}
diff --git a/internal/v1/websvc/path.go b/internal/v1/websvc/path.go
new file mode 100644
index 00000000..cfd67fd9
--- /dev/null
+++ b/internal/v1/websvc/path.go
@@ -0,0 +1,8 @@
+package websvc
+
+// Path constants
+const (
+	PathHealthCheck = "/health-check"
+
+	PathV1SystemInfo = "/api/v1/system/info"
+)
diff --git a/internal/v1/websvc/system.go b/internal/v1/websvc/system.go
new file mode 100644
index 00000000..47d0c63c
--- /dev/null
+++ b/internal/v1/websvc/system.go
@@ -0,0 +1,35 @@
+package websvc
+
+import (
+	"net/http"
+	"runtime"
+
+	"github.com/AdguardTeam/AdGuardHome/internal/version"
+)
+
+// System Handlers
+
+// RespGetV1SystemInfo describes the response of the GET /api/v1/system/info
+// HTTP API.
+type RespGetV1SystemInfo struct {
+	Arch       string   `json:"arch"`
+	Channel    string   `json:"channel"`
+	OS         string   `json:"os"`
+	NewVersion string   `json:"new_version,omitempty"`
+	Start      jsonTime `json:"start"`
+	Version    string   `json:"version"`
+}
+
+// handleGetV1SystemInfo is the handler for the GET /api/v1/system/info HTTP
+// API.
+func (svc *Service) handleGetV1SystemInfo(w http.ResponseWriter, r *http.Request) {
+	writeJSONResponse(w, r, &RespGetV1SystemInfo{
+		Arch:    runtime.GOARCH,
+		Channel: version.Channel(),
+		OS:      runtime.GOOS,
+		// TODO(a.garipov): Fill this when we have an updater.
+		NewVersion: "",
+		Start:      jsonTime(svc.start),
+		Version:    version.Version(),
+	})
+}
diff --git a/internal/v1/websvc/system_test.go b/internal/v1/websvc/system_test.go
new file mode 100644
index 00000000..49579ca5
--- /dev/null
+++ b/internal/v1/websvc/system_test.go
@@ -0,0 +1,36 @@
+package websvc_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"runtime"
+	"testing"
+	"time"
+
+	"github.com/AdguardTeam/AdGuardHome/internal/v1/websvc"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestService_handleGetV1SystemInfo(t *testing.T) {
+	_, addr := newTestServer(t)
+	u := &url.URL{
+		Scheme: "http",
+		Host:   addr,
+		Path:   websvc.PathV1SystemInfo,
+	}
+
+	body := httpGet(t, u, http.StatusOK)
+	resp := &websvc.RespGetV1SystemInfo{}
+	err := json.Unmarshal(body, resp)
+	require.NoError(t, err)
+
+	// TODO(a.garipov): Consider making version.Channel and version.Version
+	// testable and test these better.
+	assert.NotEmpty(t, resp.Channel)
+
+	assert.Equal(t, resp.Arch, runtime.GOARCH)
+	assert.Equal(t, resp.OS, runtime.GOOS)
+	assert.Equal(t, testStart, time.Time(resp.Start))
+}
diff --git a/internal/v1/websvc/websvc.go b/internal/v1/websvc/websvc.go
index e741ff3d..9af22a15 100644
--- a/internal/v1/websvc/websvc.go
+++ b/internal/v1/websvc/websvc.go
@@ -17,6 +17,7 @@ import (
 	"github.com/AdguardTeam/golibs/errors"
 	"github.com/AdguardTeam/golibs/log"
 	"github.com/AdguardTeam/golibs/netutil"
+	httptreemux "github.com/dimfeld/httptreemux/v5"
 )
 
 // Config is the AdGuard Home web service configuration structure.
@@ -32,6 +33,9 @@ type Config struct {
 	// SecureAddresses is not empty, TLS must not be nil.
 	SecureAddresses []*netutil.IPPort
 
+	// Start is the time of start of AdGuard Home.
+	Start time.Time
+
 	// Timeout is the timeout for all server operations.
 	Timeout time.Duration
 }
@@ -41,6 +45,7 @@ type Config struct {
 type Service struct {
 	tls     *tls.Config
 	servers []*http.Server
+	start   time.Time
 	timeout time.Duration
 }
 
@@ -53,11 +58,11 @@ func New(c *Config) (svc *Service) {
 
 	svc = &Service{
 		tls:     c.TLS,
+		start:   c.Start,
 		timeout: c.Timeout,
 	}
 
-	mux := http.NewServeMux()
-	mux.HandleFunc("/health-check", svc.handleGetHealthCheck)
+	mux := newMux(svc)
 
 	for _, a := range c.Addresses {
 		addr := a.String()
@@ -91,6 +96,43 @@ func New(c *Config) (svc *Service) {
 	return svc
 }
 
+// newMux returns a new HTTP request multiplexor for the AdGuard Home web
+// service.
+func newMux(svc *Service) (mux *httptreemux.ContextMux) {
+	mux = httptreemux.NewContextMux()
+
+	routes := []struct {
+		handler http.HandlerFunc
+		method  string
+		path    string
+		isJSON  bool
+	}{{
+		handler: svc.handleGetHealthCheck,
+		method:  http.MethodGet,
+		path:    PathHealthCheck,
+		isJSON:  false,
+	}, {
+		handler: svc.handleGetV1SystemInfo,
+		method:  http.MethodGet,
+		path:    PathV1SystemInfo,
+		isJSON:  true,
+	}}
+
+	for _, r := range routes {
+		var h http.HandlerFunc
+		if r.isJSON {
+			// TODO(a.garipov): Consider using httptreemux's MiddlewareFunc.
+			h = jsonMw(r.handler)
+		} else {
+			h = r.handler
+		}
+
+		mux.Handle(r.method, r.path, h)
+	}
+
+	return mux
+}
+
 // Addrs returns all addresses on which this server serves the HTTP API.  Addrs
 // must not be called until Start returns.
 func (svc *Service) Addrs() (addrs []string) {
diff --git a/internal/v1/websvc/websvc_test.go b/internal/v1/websvc/websvc_test.go
index 01b892cd..459ffd14 100644
--- a/internal/v1/websvc/websvc_test.go
+++ b/internal/v1/websvc/websvc_test.go
@@ -18,7 +18,17 @@ import (
 
 const testTimeout = 1 * time.Second
 
-func TestService_Start_getHealthCheck(t *testing.T) {
+// testStart is the server start value for tests.
+var testStart = time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
+
+// newTestServer creates and starts a new web service instance as well as its
+// sole address.  It also registers a cleanup procedure, which shuts the
+// instance down.
+//
+// TODO(a.garipov): Use svc or remove it.
+func newTestServer(t testing.TB) (svc *websvc.Service, addr string) {
+	t.Helper()
+
 	c := &websvc.Config{
 		TLS: nil,
 		Addresses: []*netutil.IPPort{{
@@ -27,9 +37,10 @@ func TestService_Start_getHealthCheck(t *testing.T) {
 		}},
 		SecureAddresses: nil,
 		Timeout:         testTimeout,
+		Start:           testStart,
 	}
 
-	svc := websvc.New(c)
+	svc = websvc.New(c)
 
 	err := svc.Start()
 	require.NoError(t, err)
@@ -44,26 +55,43 @@ func TestService_Start_getHealthCheck(t *testing.T) {
 	addrs := svc.Addrs()
 	require.Len(t, addrs, 1)
 
-	u := &url.URL{
-		Scheme: "http",
-		Host:   addrs[0],
-		Path:   "/health-check",
-	}
+	return svc, addrs[0]
+}
+
+// httpGet is a helper that performs an HTTP GET request and returns the body of
+// the response as well as checks that the status code is correct.
+//
+// TODO(a.garipov): Add helpers for other methods.
+func httpGet(t testing.TB, u *url.URL, wantCode int) (body []byte) {
+	t.Helper()
+
 	req, err := http.NewRequest(http.MethodGet, u.String(), nil)
-	require.NoError(t, err)
+	require.NoErrorf(t, err, "creating req")
 
 	httpCli := &http.Client{
 		Timeout: testTimeout,
 	}
 	resp, err := httpCli.Do(req)
-	require.NoError(t, err)
+	require.NoErrorf(t, err, "performing req")
+	require.Equal(t, wantCode, resp.StatusCode)
 
 	testutil.CleanupAndRequireSuccess(t, resp.Body.Close)
 
-	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	body, err = io.ReadAll(resp.Body)
+	require.NoErrorf(t, err, "reading body")
 
-	body, err := io.ReadAll(resp.Body)
-	require.NoError(t, err)
+	return body
+}
+
+func TestService_Start_getHealthCheck(t *testing.T) {
+	_, addr := newTestServer(t)
+	u := &url.URL{
+		Scheme: "http",
+		Host:   addr,
+		Path:   websvc.PathHealthCheck,
+	}
+
+	body := httpGet(t, u, http.StatusOK)
 
 	assert.Equal(t, []byte("OK"), body)
 }
diff --git a/openapi/v1.yaml b/openapi/v1.yaml
index 30c318bc..a9092c98 100644
--- a/openapi/v1.yaml
+++ b/openapi/v1.yaml
@@ -3393,11 +3393,17 @@
       'description': >
         Information about the AdGuard Home server.
       'example':
+        'arch': 'amd64'
         'channel': 'release'
-        'new_version': 'v0.106.1'
+        'new_version': 'v0.108.1'
+        'os': 'linux'
         'start': 1614345496000
-        'version': 'v0.106.0'
+        'version': 'v0.108.0'
       'properties':
+        'arch':
+          'description': >
+            CPU architecture.
+          'type': 'string'
         'channel':
           '$ref': '#/components/schemas/Channel'
         'new_version':
@@ -3405,6 +3411,10 @@
             New available version of AdGuard Home to which the server can be
             updated, if any.  If there are none, this field is absent.
           'type': 'string'
+        'os':
+          'description': >
+            Operating system type.
+          'type': 'string'
         'start':
           'description': >
             Unix time at which AdGuard Home started working, in milliseconds.
@@ -3415,7 +3425,9 @@
             Current AdGuard Home version.
           'type': 'string'
       'required':
+      - 'arch'
       - 'channel'
+      - 'os'
       - 'start'
       - 'version'
       'type': 'object'