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
This commit is contained in:
Ainar Garipov 2022-05-12 17:41:39 +03:00
parent 58515fce43
commit f289f4b1b6
11 changed files with 260 additions and 17 deletions

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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,
})

View file

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

View file

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

View file

@ -0,0 +1,8 @@
package websvc
// Path constants
const (
PathHealthCheck = "/health-check"
PathV1SystemInfo = "/api/v1/system/info"
)

View file

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

View file

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

View file

@ -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) {

View file

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

View file

@ -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'