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/NYTimes/gziphandler v1.1.1
github.com/ameshkov/dnscrypt/v2 v2.2.3 github.com/ameshkov/dnscrypt/v2 v2.2.3
github.com/digineo/go-ipset/v2 v2.2.1 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/fsnotify/fsnotify v1.5.4
github.com/go-ping/ping v0.0.0-20211130115550-779d1e919534 github.com/go-ping/ping v0.0.0-20211130115550-779d1e919534
github.com/google/go-cmp v0.5.7 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/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 h1:k6skY+0fMqeUjjeWO/m5OuWPSZUAn7AucHMnQ1MX77g=
github.com/digineo/go-ipset/v2 v2.2.1/go.mod h1:wBsNzJlZlABHUITkesrggFnZQtgW5wkqw1uo8Qxe0VU= 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/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/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= 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) { func Main(clientBuildFS fs.FS) {
// # Initial Configuration // # Initial Configuration
rand.Seed(time.Now().UnixNano()) start := time.Now()
rand.Seed(start.UnixNano())
// TODO(a.garipov): Set up logging. // TODO(a.garipov): Set up logging.
@ -35,6 +36,7 @@ func Main(clientBuildFS fs.FS) {
IP: net.IP{127, 0, 0, 1}, IP: net.IP{127, 0, 0, 1},
Port: 3001, Port: 3001,
}}, }},
Start: start,
Timeout: 60 * time.Second, 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/errors"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
"github.com/AdguardTeam/golibs/netutil" "github.com/AdguardTeam/golibs/netutil"
httptreemux "github.com/dimfeld/httptreemux/v5"
) )
// Config is the AdGuard Home web service configuration structure. // 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 is not empty, TLS must not be nil.
SecureAddresses []*netutil.IPPort SecureAddresses []*netutil.IPPort
// Start is the time of start of AdGuard Home.
Start time.Time
// Timeout is the timeout for all server operations. // Timeout is the timeout for all server operations.
Timeout time.Duration Timeout time.Duration
} }
@ -41,6 +45,7 @@ type Config struct {
type Service struct { type Service struct {
tls *tls.Config tls *tls.Config
servers []*http.Server servers []*http.Server
start time.Time
timeout time.Duration timeout time.Duration
} }
@ -53,11 +58,11 @@ func New(c *Config) (svc *Service) {
svc = &Service{ svc = &Service{
tls: c.TLS, tls: c.TLS,
start: c.Start,
timeout: c.Timeout, timeout: c.Timeout,
} }
mux := http.NewServeMux() mux := newMux(svc)
mux.HandleFunc("/health-check", svc.handleGetHealthCheck)
for _, a := range c.Addresses { for _, a := range c.Addresses {
addr := a.String() addr := a.String()
@ -91,6 +96,43 @@ func New(c *Config) (svc *Service) {
return svc 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 // Addrs returns all addresses on which this server serves the HTTP API. Addrs
// must not be called until Start returns. // must not be called until Start returns.
func (svc *Service) Addrs() (addrs []string) { func (svc *Service) Addrs() (addrs []string) {

View File

@ -18,7 +18,17 @@ import (
const testTimeout = 1 * time.Second 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{ c := &websvc.Config{
TLS: nil, TLS: nil,
Addresses: []*netutil.IPPort{{ Addresses: []*netutil.IPPort{{
@ -27,9 +37,10 @@ func TestService_Start_getHealthCheck(t *testing.T) {
}}, }},
SecureAddresses: nil, SecureAddresses: nil,
Timeout: testTimeout, Timeout: testTimeout,
Start: testStart,
} }
svc := websvc.New(c) svc = websvc.New(c)
err := svc.Start() err := svc.Start()
require.NoError(t, err) require.NoError(t, err)
@ -44,26 +55,43 @@ func TestService_Start_getHealthCheck(t *testing.T) {
addrs := svc.Addrs() addrs := svc.Addrs()
require.Len(t, addrs, 1) require.Len(t, addrs, 1)
u := &url.URL{ return svc, addrs[0]
Scheme: "http",
Host: addrs[0],
Path: "/health-check",
} }
// 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) req, err := http.NewRequest(http.MethodGet, u.String(), nil)
require.NoError(t, err) require.NoErrorf(t, err, "creating req")
httpCli := &http.Client{ httpCli := &http.Client{
Timeout: testTimeout, Timeout: testTimeout,
} }
resp, err := httpCli.Do(req) 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) 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) return body
require.NoError(t, err) }
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) assert.Equal(t, []byte("OK"), body)
} }

View File

@ -3393,11 +3393,17 @@
'description': > 'description': >
Information about the AdGuard Home server. Information about the AdGuard Home server.
'example': 'example':
'arch': 'amd64'
'channel': 'release' 'channel': 'release'
'new_version': 'v0.106.1' 'new_version': 'v0.108.1'
'os': 'linux'
'start': 1614345496000 'start': 1614345496000
'version': 'v0.106.0' 'version': 'v0.108.0'
'properties': 'properties':
'arch':
'description': >
CPU architecture.
'type': 'string'
'channel': 'channel':
'$ref': '#/components/schemas/Channel' '$ref': '#/components/schemas/Channel'
'new_version': 'new_version':
@ -3405,6 +3411,10 @@
New available version of AdGuard Home to which the server can be New available version of AdGuard Home to which the server can be
updated, if any. If there are none, this field is absent. updated, if any. If there are none, this field is absent.
'type': 'string' 'type': 'string'
'os':
'description': >
Operating system type.
'type': 'string'
'start': 'start':
'description': > 'description': >
Unix time at which AdGuard Home started working, in milliseconds. Unix time at which AdGuard Home started working, in milliseconds.
@ -3415,7 +3425,9 @@
Current AdGuard Home version. Current AdGuard Home version.
'type': 'string' 'type': 'string'
'required': 'required':
- 'arch'
- 'channel' - 'channel'
- 'os'
- 'start' - 'start'
- 'version' - 'version'
'type': 'object' 'type': 'object'