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'