From b2b975be5d29d9b2e47163ce101976a780c275ab Mon Sep 17 00:00:00 2001 From: Michael Marineau Date: Thu, 4 May 2017 14:21:38 -0700 Subject: [PATCH 01/10] client: begin omaha client implementation This first commit just covers the basic data structures. --- omaha/client/client.go | 154 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 omaha/client/client.go diff --git a/omaha/client/client.go b/omaha/client/client.go new file mode 100644 index 0000000..72ee957 --- /dev/null +++ b/omaha/client/client.go @@ -0,0 +1,154 @@ +// Copyright 2017 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package client provides a general purpose Omaha update client implementation. +package client + +import ( + "errors" + "fmt" + "net/url" + + "github.com/satori/go.uuid" +) + +// Client supports managing multiple apps using a single server. +type Client struct { + apiEndpoint string + clientVersion string + userID string + sessionID string + isMachine bool + apps map[string]*AppClient +} + +// AppClient supports managing a single application. +type AppClient struct { + *Client + appID string + track string + version string +} + +// New creates an omaha client for updating one or more applications. +// userID must be a persistent unique identifier of this update client. +func New(serverURL, userID string) (*Client, error) { + if userID == "" { + return nil, errors.New("omaha: empty user identifier") + } + + c := &Client{ + clientVersion: "go-omaha", + userID: userID, + sessionID: uuid.NewV4().String(), + apps: make(map[string]*AppClient), + } + + if err := c.SetServerURL(serverURL); err != nil { + return nil, err + } + + return c, nil +} + +// SetServerURL changes the Omaha server this client talks to. +// If the URL does not include a path component /v1/update/ is assumed. +func (c *Client) SetServerURL(serverURL string) error { + u, err := url.Parse(serverURL) + if err != nil { + return fmt.Errorf("omaha: invalid server URL: %v", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("omaha: invalid server protocol: %s", u) + } + if u.Host == "" { + return fmt.Errorf("omaha: invalid server host: %s", u) + } + if u.Path == "" || u.Path == "/" { + u.Path = "/v1/update/" + } + + c.apiEndpoint = u.String() + return nil +} + +// SetClientVersion sets the identifier of this updater application. +// e.g. "update_engine-0.1.0". Default is "go-omaha". +func (c *Client) SetClientVersion(clientVersion string) { + c.clientVersion = clientVersion +} + +// AppClient gets the application client for the given application ID. +func (c *Client) AppClient(appID string) (*AppClient, error) { + if app, ok := c.apps[appID]; ok { + return app, nil + } + + return nil, fmt.Errorf("omaha: missing app client %q", appID) +} + +// NewAppClient creates a new application client. +func (c *Client) NewAppClient(appID, appVersion string) (*AppClient, error) { + if _, ok := c.apps[appID]; ok { + return nil, fmt.Errorf("omaha: duplicate app client %q", appID) + } + + ac := &AppClient{ + Client: c, + appID: appID, + } + c.apps[appID] = ac + + return ac, nil +} + +// NewAppClient creates a single application client. +// Shorthand for New(serverURL, userID).NewAppClient(appID, appVersion). +func NewAppClient(serverURL, userID, appID, appVersion string) (*AppClient, error) { + c, err := New(serverURL, userID) + if err != nil { + return nil, err + } + + ac, err := c.NewAppClient(appID, appVersion) + if err := ac.SetVersion(appVersion); err != nil { + return nil, err + } + + return ac, nil +} + +// SetVersion changes the application version. +func (ac *AppClient) SetVersion(version string) error { + if version == "" { + return errors.New("omaha: empty application version") + } + + ac.version = version + return nil +} + +// SetTrack sets the application update track or group. +// This is a update_engine/Core Update protocol extension. +func (ac *AppClient) SetTrack(track string) error { + // Although track is an omaha extension and theoretically not required + // our Core Update server requires track to be set to a valid id/name. + // TODO: deprecate track and use the standard cohort protocol fields. + if track == "" { + return errors.New("omaha: empty application update track/group") + } + + ac.track = track + return nil +} From b8149cc6832aa37f78be6a351a092c4ad5d5d0b2 Mon Sep 17 00:00:00 2001 From: Michael Marineau Date: Thu, 18 May 2017 12:33:24 -0700 Subject: [PATCH 02/10] client: add extended http client for making omaha api requests Supports encoding/decoding omaha xml and retrying on transient failures. --- omaha/client/http.go | 78 +++++++++++++++++++++++++++++++++++++++ omaha/client/http_test.go | 57 ++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 omaha/client/http.go create mode 100644 omaha/client/http_test.go diff --git a/omaha/client/http.go b/omaha/client/http.go new file mode 100644 index 0000000..bde2610 --- /dev/null +++ b/omaha/client/http.go @@ -0,0 +1,78 @@ +// Copyright 2017 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "bytes" + "encoding/xml" + "fmt" + "net" + "net/http" + "time" + + "github.com/coreos/go-omaha/omaha" +) + +const ( + defaultTimeout = 90 * time.Second + defaultTries = 7 +) + +// httpClient extends the standard http.Client to support xml encoding +// and decoding as well as automatic retries on transient failures. +type httpClient struct { + http.Client +} + +func newHTTPClient() *httpClient { + return &httpClient{http.Client{ + Timeout: defaultTimeout, + }} +} + +// doPost sends a single HTTP POST, returning a parsed omaha response. +func (hc *httpClient) doPost(url string, reqBody []byte) (*omaha.Response, error) { + resp, err := hc.Post(url, "text/xml; charset=utf-8", bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + contentType := resp.Header.Get("Content-Type") + return omaha.ParseResponse(contentType, resp.Body) +} + +// Omaha encodes and sends an omaha request, retrying on any transient errors. +func (hc *httpClient) Omaha(url string, req *omaha.Request) (resp *omaha.Response, err error) { + buf := bytes.NewBufferString(xml.Header) + enc := xml.NewEncoder(buf) + if err := enc.Encode(req); err != nil { + return nil, fmt.Errorf("omaha: failed to encode request: %v", err) + } + + for i := 0; i < defaultTries; i++ { + resp, err = hc.doPost(url, buf.Bytes()) + if neterr, ok := err.(net.Error); ok && neterr.Temporary() { + // TODO(marineam): add exponential backoff + continue + } + break + } + if err != nil { + return nil, fmt.Errorf("omaha: request failed: %v", err) + } + + return resp, nil +} diff --git a/omaha/client/http_test.go b/omaha/client/http_test.go new file mode 100644 index 0000000..fa7eed2 --- /dev/null +++ b/omaha/client/http_test.go @@ -0,0 +1,57 @@ +// Copyright 2017 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "testing" + + "github.com/coreos/go-omaha/omaha" +) + +const ( + sampleRequest = ` + + + + + + + + +` +) + +func TestHTTPClientDoPost(t *testing.T) { + s, err := omaha.NewTrivialServer("127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer s.Destroy() + go s.Serve() + + c := newHTTPClient() + url := "http://" + s.Addr().String() + "/v1/update/" + + resp, err := c.doPost(url, []byte(sampleRequest)) + if err != nil { + t.Fatal(err) + } + if len(resp.Apps) != 1 { + t.Fatalf("Should be 1 app, not %d", len(resp.Apps)) + } + if resp.Apps[0].Status != omaha.AppOK { + t.Fatalf("Bad apps status: %q", resp.Apps[0].Status) + } +} From e6f3abe15e00e3594109de976c2b18c015d1f8c7 Mon Sep 17 00:00:00 2001 From: Michael Marineau Date: Thu, 11 May 2017 12:43:42 -0700 Subject: [PATCH 03/10] client: support retrying requests after transient HTTP errors Wraps failed http.Request objects in a net.Error interface. --- omaha/client/error.go | 57 ++++++++++++++++++++ omaha/client/http.go | 9 +++- omaha/client/http_test.go | 109 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 omaha/client/error.go diff --git a/omaha/client/error.go b/omaha/client/error.go new file mode 100644 index 0000000..cfe8f86 --- /dev/null +++ b/omaha/client/error.go @@ -0,0 +1,57 @@ +// Copyright 2017 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "net/http" +) + +// httpError implements error and net.Error for http responses. +type httpError struct { + *http.Response +} + +func (he *httpError) Error() string { + return "http error: " + he.Status +} + +func (he *httpError) Timeout() bool { + switch he.StatusCode { + case http.StatusRequestTimeout: // 408 + return true + case http.StatusGatewayTimeout: // 504 + return true + default: + return false + } +} + +func (he *httpError) Temporary() bool { + if he.Timeout() { + return true + } + switch he.StatusCode { + case http.StatusTooManyRequests: // 429 + return true + case http.StatusInternalServerError: // 500 + return true + case http.StatusBadGateway: // 502 + return true + case http.StatusServiceUnavailable: // 503 + return true + default: + return false + } +} diff --git a/omaha/client/http.go b/omaha/client/http.go index bde2610..7c90378 100644 --- a/omaha/client/http.go +++ b/omaha/client/http.go @@ -51,7 +51,14 @@ func (hc *httpClient) doPost(url string, reqBody []byte) (*omaha.Response, error defer resp.Body.Close() contentType := resp.Header.Get("Content-Type") - return omaha.ParseResponse(contentType, resp.Body) + omahaResp, err := omaha.ParseResponse(contentType, resp.Body) + + // Prefer reporting HTTP errors over XML parsing errors. + if resp.StatusCode != http.StatusOK { + err = &httpError{resp} + } + + return omahaResp, err } // Omaha encodes and sends an omaha request, retrying on any transient errors. diff --git a/omaha/client/http_test.go b/omaha/client/http_test.go index fa7eed2..9dda26f 100644 --- a/omaha/client/http_test.go +++ b/omaha/client/http_test.go @@ -15,6 +15,9 @@ package client import ( + "net" + "net/http" + "strings" "testing" "github.com/coreos/go-omaha/omaha" @@ -55,3 +58,109 @@ func TestHTTPClientDoPost(t *testing.T) { t.Fatalf("Bad apps status: %q", resp.Apps[0].Status) } } + +type flakyHandler struct { + omaha.OmahaHandler + flakes int + reqs int +} + +func (f *flakyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.reqs++ + if f.flakes > 0 { + f.flakes-- + http.Error(w, "Flake!", http.StatusInternalServerError) + return + } + f.OmahaHandler.ServeHTTP(w, r) +} + +type flakyServer struct { + l net.Listener + s *http.Server + h *flakyHandler +} + +func newFlakyServer() (*flakyServer, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + + f := &flakyServer{ + l: l, + s: &http.Server{}, + h: &flakyHandler{ + OmahaHandler: omaha.OmahaHandler{ + Updater: omaha.UpdaterStub{}, + }, + flakes: 1, + }, + } + f.s.Handler = f.h + + go f.s.Serve(l) + return f, nil +} + +func TestHTTPClientError(t *testing.T) { + f, err := newFlakyServer() + if err != nil { + t.Fatal(err) + } + defer f.l.Close() + + c := newHTTPClient() + url := "http://" + f.l.Addr().String() + + _, err = c.doPost(url, []byte(sampleRequest)) + switch err := err.(type) { + case nil: + t.Fatal("doPost succeeded but should have failed") + case *httpError: + if err.StatusCode != http.StatusInternalServerError { + t.Fatalf("Unexpected http error: %v", err) + } + if err.Timeout() { + t.Fatal("http 500 error reported as timeout") + } + if !err.Temporary() { + t.Fatal("http 500 error not reported as temporary") + } + default: + t.Fatalf("Unexpected error: %v", err) + } +} + +func TestHTTPClientRetry(t *testing.T) { + f, err := newFlakyServer() + if err != nil { + t.Fatal(err) + } + defer f.l.Close() + + req, err := omaha.ParseRequest("", strings.NewReader(sampleRequest)) + if err != nil { + t.Fatal(err) + } + + c := newHTTPClient() + url := "http://" + f.l.Addr().String() + + resp, err := c.Omaha(url, req) + if err != nil { + t.Fatal(err) + } + + if len(resp.Apps) != 1 { + t.Fatalf("Should be 1 app, not %d", len(resp.Apps)) + } + + if resp.Apps[0].Status != omaha.AppOK { + t.Fatalf("Bad apps status: %q", resp.Apps[0].Status) + } + + if f.h.reqs != 2 { + t.Fatalf("Server received %d requests, not 2", f.h.reqs) + } +} From 45e1ea6221225164b086a1c7964168d980839b70 Mon Sep 17 00:00:00 2001 From: Michael Marineau Date: Fri, 19 May 2017 13:32:27 -0700 Subject: [PATCH 04/10] client: report sensible errors if response is excessively large or empty --- omaha/client/error.go | 16 ++++++++++++++ omaha/client/http.go | 12 +++++++++- omaha/client/http_test.go | 46 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/omaha/client/error.go b/omaha/client/error.go index cfe8f86..2132295 100644 --- a/omaha/client/error.go +++ b/omaha/client/error.go @@ -15,9 +15,25 @@ package client import ( + "encoding/xml" + "errors" + "io" "net/http" ) +var ( + bodySizeError = errors.New("http response exceeded 1MB") + bodyEmptyError = errors.New("http response was empty") +) + +// xml doesn't return the standard io.ErrUnexpectedEOF so check for both. +func isUnexpectedEOF(err error) bool { + if xerr, ok := err.(*xml.SyntaxError); ok { + return xerr.Msg == "unexpected EOF" + } + return err == io.ErrUnexpectedEOF +} + // httpError implements error and net.Error for http responses. type httpError struct { *http.Response diff --git a/omaha/client/http.go b/omaha/client/http.go index 7c90378..2db32f4 100644 --- a/omaha/client/http.go +++ b/omaha/client/http.go @@ -18,6 +18,7 @@ import ( "bytes" "encoding/xml" "fmt" + "io" "net" "net/http" "time" @@ -50,8 +51,17 @@ func (hc *httpClient) doPost(url string, reqBody []byte) (*omaha.Response, error } defer resp.Body.Close() + // A response over 1M in size is certainly bogus. + respBody := &io.LimitedReader{R: resp.Body, N: 1024 * 1024} contentType := resp.Header.Get("Content-Type") - omahaResp, err := omaha.ParseResponse(contentType, resp.Body) + omahaResp, err := omaha.ParseResponse(contentType, respBody) + + // Report a more sensible error if we truncated the body. + if isUnexpectedEOF(err) && respBody.N <= 0 { + err = bodySizeError + } else if err == io.EOF { + err = bodyEmptyError + } // Prefer reporting HTTP errors over XML parsing errors. if resp.StatusCode != http.StatusOK { diff --git a/omaha/client/http_test.go b/omaha/client/http_test.go index 9dda26f..9d9c470 100644 --- a/omaha/client/http_test.go +++ b/omaha/client/http_test.go @@ -15,6 +15,7 @@ package client import ( + "bytes" "net" "net/http" "strings" @@ -164,3 +165,48 @@ func TestHTTPClientRetry(t *testing.T) { t.Fatalf("Server received %d requests, not 2", f.h.reqs) } } + +// should result in an unexected EOF +func largeHandler1(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(``)) + w.Write(bytes.Repeat([]byte{' '}, 2*1024*1024)) + w.Write([]byte(``)) +} + +// should result in an EOF +func largeHandler2(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml; charset=utf-8") + w.Write(bytes.Repeat([]byte{' '}, 2*1024*1024)) +} + +func TestHTTPClientLarge(t *testing.T) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + + s := &http.Server{ + Handler: http.HandlerFunc(largeHandler1), + } + go s.Serve(l) + + c := newHTTPClient() + url := "http://" + l.Addr().String() + + _, err = c.doPost(url, []byte(sampleRequest)) + if err != bodySizeError { + t.Errorf("Unexpected error: %v", err) + } + + // switch to failing before XML is read instead of half-way + // through (which results in a different error internally) + s.Handler = http.HandlerFunc(largeHandler2) + + _, err = c.doPost(url, []byte(sampleRequest)) + if err != bodyEmptyError { + t.Errorf("Unexpected error: %v", err) + } +} From d8ad567e7f71f34e3b6a0af5d3261b2ed5f5a4b5 Mon Sep 17 00:00:00 2001 From: Michael Marineau Date: Fri, 12 May 2017 12:56:21 -0700 Subject: [PATCH 05/10] protocol: add GetApp helper methods --- omaha/protocol.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/omaha/protocol.go b/omaha/protocol.go index 98cce51..0cc4aa5 100644 --- a/omaha/protocol.go +++ b/omaha/protocol.go @@ -79,6 +79,15 @@ func (r *Request) AddApp(id, version string) *AppRequest { return a } +func (r *Request) GetApp(id string) *AppRequest { + for _, app := range r.Apps { + if app.ID == id { + return app + } + } + return nil +} + type AppRequest struct { Ping *PingRequest `xml:"ping"` UpdateCheck *UpdateRequest `xml:"updatecheck"` @@ -181,6 +190,15 @@ func (r *Response) AddApp(id string, status AppStatus) *AppResponse { return a } +func (r *Response) GetApp(id string) *AppResponse { + for _, app := range r.Apps { + if app.ID == id { + return app + } + } + return nil +} + type AppResponse struct { Ping *PingResponse `xml:"ping"` UpdateCheck *UpdateResponse `xml:"updatecheck"` From afab572db3a92813552e246e4f3c96d6b9ddd323 Mon Sep 17 00:00:00 2001 From: Michael Marineau Date: Wed, 17 May 2017 15:03:05 -0700 Subject: [PATCH 06/10] protocol: change IsMachine flag to int This flag is a boolean "0" or "1", with "0" as the default so keeping the "omitempty" xml option is ok. --- omaha/protocol.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/omaha/protocol.go b/omaha/protocol.go index 0cc4aa5..8a2d411 100644 --- a/omaha/protocol.go +++ b/omaha/protocol.go @@ -34,7 +34,7 @@ type Request struct { Apps []*AppRequest `xml:"app"` Protocol string `xml:"protocol,attr"` InstallSource string `xml:"installsource,attr,omitempty"` - IsMachine string `xml:"ismachine,attr,omitempty"` + IsMachine int `xml:"ismachine,attr,omitempty"` RequestID string `xml:"requestid,attr,omitempty"` SessionID string `xml:"sessionid,attr,omitempty"` TestSource string `xml:"testsource,attr,omitempty"` From 5a03e1d1835382c399f71dec3f333706a859298c Mon Sep 17 00:00:00 2001 From: Michael Marineau Date: Thu, 18 May 2017 12:53:35 -0700 Subject: [PATCH 07/10] codes: improve app and update status error messages --- omaha/codes.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/omaha/codes.go b/omaha/codes.go index 822f4b1..775b95b 100644 --- a/omaha/codes.go +++ b/omaha/codes.go @@ -160,7 +160,7 @@ const ( // Make AppStatus easy to use as an error func (a AppStatus) Error() string { - return string(a) + return "omaha: app status " + string(a) } type UpdateStatus string @@ -177,5 +177,5 @@ const ( // Make UpdateStatus easy to use as an error func (u UpdateStatus) Error() string { - return string(u) + return "omaha: update status " + string(u) } From ad277db627fce7b36a03cc568a7259657b4e5dfb Mon Sep 17 00:00:00 2001 From: Michael Marineau Date: Fri, 19 May 2017 13:41:52 -0700 Subject: [PATCH 08/10] client: implement event, ping, and updatecheck For simplicity one request is sent for each event/ping/check for each application. In the future it would be wise to batch together multiple events and multiple applications to avoid excessive chatter. --- omaha/client/client.go | 120 ++++++++++++++++++++++++ omaha/client/client_test.go | 181 ++++++++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 omaha/client/client_test.go diff --git a/omaha/client/client.go b/omaha/client/client.go index 72ee957..3be8c44 100644 --- a/omaha/client/client.go +++ b/omaha/client/client.go @@ -21,10 +21,13 @@ import ( "net/url" "github.com/satori/go.uuid" + + "github.com/coreos/go-omaha/omaha" ) // Client supports managing multiple apps using a single server. type Client struct { + apiClient *httpClient apiEndpoint string clientVersion string userID string @@ -49,6 +52,7 @@ func New(serverURL, userID string) (*Client, error) { } c := &Client{ + apiClient: newHTTPClient(), clientVersion: "go-omaha", userID: userID, sessionID: uuid.NewV4().String(), @@ -152,3 +156,119 @@ func (ac *AppClient) SetTrack(track string) error { ac.track = track return nil } + +func (ac *AppClient) UpdateCheck() (*omaha.UpdateResponse, error) { + req := ac.newReq() + app := req.Apps[0] + app.AddPing() + app.AddUpdateCheck() + + appResp, err := ac.doReq(ac.apiEndpoint, req) + if err != nil { + return nil, err + } + + if appResp.Ping == nil { + return nil, fmt.Errorf("omaha: ping status missing from response") + } + + if appResp.Ping.Status != "ok" { + return nil, fmt.Errorf("omaha: ping status %s", appResp.Ping.Status) + } + + if appResp.UpdateCheck == nil { + return nil, fmt.Errorf("omaha: update check missing from response") + } + + if appResp.UpdateCheck.Status != omaha.UpdateOK { + return nil, appResp.UpdateCheck.Status + } + + return appResp.UpdateCheck, nil +} + +func (ac *AppClient) Ping() error { + req := ac.newReq() + app := req.Apps[0] + app.AddPing() + + appResp, err := ac.doReq(ac.apiEndpoint, req) + if err != nil { + return err + } + + if appResp.Ping == nil { + return fmt.Errorf("omaha: ping status missing from response") + } + + if appResp.Ping.Status != "ok" { + return fmt.Errorf("omaha: ping status %s", appResp.Ping.Status) + } + + return nil +} + +func (ac *AppClient) Event(event *omaha.EventRequest) error { + req := ac.newReq() + app := req.Apps[0] + app.Events = append(app.Events, event) + + appResp, err := ac.doReq(ac.apiEndpoint, req) + if err != nil { + return err + } + + if len(appResp.Events) == 0 { + return fmt.Errorf("omaha: event status missing from response") + } + + if appResp.Events[0].Status != "ok" { + return fmt.Errorf("omaha: event status %s", appResp.Events[0].Status) + } + + return nil +} + +func (ac *AppClient) newReq() *omaha.Request { + req := omaha.NewRequest() + req.Version = ac.clientVersion + req.UserID = ac.userID + req.SessionID = ac.sessionID + if ac.isMachine { + req.IsMachine = 1 + } + + app := req.AddApp(ac.appID, ac.version) + app.Track = ac.track + + // MachineID and BootID are non-standard fields used by CoreOS' + // update_engine and Core Update. Copy their values from the + // standard UserID and SessionID. Eventually the non-standard + // fields should be deprecated. + app.MachineID = req.UserID + app.BootID = req.SessionID + + return req +} + +func (ac *AppClient) doReq(url string, req *omaha.Request) (*omaha.AppResponse, error) { + if len(req.Apps) != 1 { + panic(fmt.Errorf("unexpected number of apps: %d", len(req.Apps))) + } + appID := req.Apps[0].ID + resp, err := ac.apiClient.Omaha(url, req) + if err != nil { + return nil, err + } + + appResp := resp.GetApp(appID) + if appResp == nil { + return nil, fmt.Errorf("omaha: app %s missing from response", appID) + } + + if appResp.Status != omaha.AppOK { + return nil, appResp.Status + } + + return appResp, nil +} diff --git a/omaha/client/client_test.go b/omaha/client/client_test.go new file mode 100644 index 0000000..05ee0d0 --- /dev/null +++ b/omaha/client/client_test.go @@ -0,0 +1,181 @@ +// Copyright 2017 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package client + +import ( + "reflect" + "testing" + + "github.com/coreos/go-omaha/omaha" +) + +// implements omaha.Updater +type recorder struct { + t *testing.T + update *omaha.Update + checks []*omaha.UpdateRequest + events []*omaha.EventRequest + pings []*omaha.PingRequest +} + +func newRecordingServer(t *testing.T, u *omaha.Update) (*recorder, *omaha.Server) { + r := &recorder{t: t, update: u} + s, err := omaha.NewServer("127.0.0.1:0", r) + if err != nil { + t.Fatal(err) + } + go s.Serve() + return r, s +} + +func (r *recorder) CheckApp(req *omaha.Request, app *omaha.AppRequest) error { + // CheckApp is meant for checking if app.ID is valid but we don't + // care and accept any ID. Instead this is just a convenient place + // to check that all requests are well formed. + if len(req.SessionID) != 36 { + r.t.Errorf("SessionID %q is not a UUID", req.SessionID) + } + if app.BootID != req.SessionID { + r.t.Errorf("BootID %q != SessionID %q", app.BootID, req.SessionID) + } + if req.UserID == "" { + r.t.Error("UserID is blank") + } + if app.MachineID != req.UserID { + r.t.Errorf("MachineID %q != UserID %q", app.MachineID, req.UserID) + } + if app.Version == "" { + r.t.Error("App Version is blank") + } + return nil +} + +func (r *recorder) CheckUpdate(req *omaha.Request, app *omaha.AppRequest) (*omaha.Update, error) { + r.checks = append(r.checks, app.UpdateCheck) + if r.update == nil { + return nil, omaha.NoUpdate + } else { + return r.update, nil + } +} + +func (r *recorder) Event(req *omaha.Request, app *omaha.AppRequest, event *omaha.EventRequest) { + r.events = append(r.events, event) +} + +func (r *recorder) Ping(req *omaha.Request, app *omaha.AppRequest) { + r.pings = append(r.pings, app.Ping) +} + +func TestClientNoUpdate(t *testing.T) { + r, s := newRecordingServer(t, nil) + defer s.Destroy() + + url := "http://" + s.Addr().String() + ac, err := NewAppClient(url, "client-id", "app-id", "0.0.0") + if err != nil { + t.Fatal(err) + } + + if _, err := ac.UpdateCheck(); err != omaha.NoUpdate { + t.Fatalf("UpdateCheck id not return NoUpdate: %v", err) + } + + if len(r.pings) != 1 { + t.Fatalf("expected 1 ping, not %d", len(r.pings)) + } + + if len(r.checks) != 1 { + t.Fatalf("expected 1 update check, not %d", len(r.checks)) + } +} + +func TestClientWithUpdate(t *testing.T) { + r, s := newRecordingServer(t, &omaha.Update{ + Manifest: omaha.Manifest{ + Version: "1.1.1", + }, + }) + defer s.Destroy() + + url := "http://" + s.Addr().String() + ac, err := NewAppClient(url, "client-id", "app-id", "0.0.0") + if err != nil { + t.Fatal(err) + } + + update, err := ac.UpdateCheck() + if err != nil { + t.Fatal(err) + } + + if update.Manifest.Version != "1.1.1" { + t.Fatalf("expected version 1.1.1, not %s", update.Manifest.Version) + } + + if len(r.pings) != 1 { + t.Fatalf("expected 1 ping, not %d", len(r.pings)) + } + + if len(r.checks) != 1 { + t.Fatalf("expected 1 update check, not %d", len(r.checks)) + } +} + +func TestClientPing(t *testing.T) { + r, s := newRecordingServer(t, nil) + defer s.Destroy() + + url := "http://" + s.Addr().String() + ac, err := NewAppClient(url, "client-id", "app-id", "0.0.0") + if err != nil { + t.Fatal(err) + } + + if err := ac.Ping(); err != nil { + t.Fatal(err) + } + + if len(r.pings) != 1 { + t.Fatalf("expected 1 ping, not %d", len(r.pings)) + } +} + +func TestClientEvent(t *testing.T) { + r, s := newRecordingServer(t, nil) + defer s.Destroy() + + url := "http://" + s.Addr().String() + ac, err := NewAppClient(url, "client-id", "app-id", "0.0.0") + if err != nil { + t.Fatal(err) + } + + event := &omaha.EventRequest{ + Type: omaha.EventTypeDownloadComplete, + Result: omaha.EventResultSuccess, + } + if err := ac.Event(event); err != nil { + t.Fatal(err) + } + + if len(r.events) != 1 { + t.Fatalf("expected 1 event, not %d", len(r.events)) + } + + if !reflect.DeepEqual(event, r.events[0]) { + t.Fatalf("sent != received:\n%#v\n%#v", event, r.events[0]) + } +} From 73e12a0154b5a5453472ffdd8b49604553f26620 Mon Sep 17 00:00:00 2001 From: Michael Marineau Date: Thu, 25 May 2017 13:11:24 -0700 Subject: [PATCH 09/10] client: add support for machine-wide omaha clients Uses machine id and boot id for user and session respectively, matching the existing behavior of update_engine. --- omaha/client/machine_linux.go | 75 ++++++++++++++++++++++++++++++ omaha/client/machine_linux_test.go | 48 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 omaha/client/machine_linux.go create mode 100644 omaha/client/machine_linux_test.go diff --git a/omaha/client/machine_linux.go b/omaha/client/machine_linux.go new file mode 100644 index 0000000..3661318 --- /dev/null +++ b/omaha/client/machine_linux.go @@ -0,0 +1,75 @@ +// Copyright 2017 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// +build linux + +package client + +import ( + "bytes" + "fmt" + "io/ioutil" +) + +const ( + machineIDPath = "/etc/machine-id" + bootIDPath = "/proc/sys/kernel/random/boot_id" +) + +// NewMachineClient creates a machine-wide client, updating applications +// that may be used by multiple users. On Linux the system's machine id +// is used as the user id, and boot id is used as the omaha session id. +func NewMachineClient(serverURL string) (*Client, error) { + machineID, err := ioutil.ReadFile(machineIDPath) + if err != nil { + fmt.Errorf("omaha: failed to read machine id: %v", err) + } + + machineID = bytes.TrimSpace(machineID) + // Although machineID should be a UUID, it is formatted as a + // plain hex string, omitting the normal '-' separators, so it + // should be 32 bytes long. It would be nice to reformat it to + // add the '-' chars but update_engine doesn't so stick with its + // behavior for now. + if len(machineID) < 32 { + fmt.Errorf("omaha: incomplete machine id: %q", + machineID) + } + + bootID, err := ioutil.ReadFile(bootIDPath) + if err != nil { + fmt.Errorf("omaha: failed to read boot id: %v", err) + } + + bootID = bytes.TrimSpace(bootID) + // unlike machineID, bootID *does* include '-' chars. + if len(bootID) < 36 { + fmt.Errorf("omaha: incomplete boot id: %q", bootID) + } + + c := &Client{ + apiClient: newHTTPClient(), + clientVersion: "go-omaha", + userID: string(machineID), + sessionID: string(bootID), + isMachine: true, + apps: make(map[string]*AppClient), + } + + if err := c.SetServerURL(serverURL); err != nil { + return nil, err + } + + return c, nil +} diff --git a/omaha/client/machine_linux_test.go b/omaha/client/machine_linux_test.go new file mode 100644 index 0000000..022d6b8 --- /dev/null +++ b/omaha/client/machine_linux_test.go @@ -0,0 +1,48 @@ +// Copyright 2017 CoreOS, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// +build linux + +package client + +import ( + "bytes" + "io/ioutil" + "testing" +) + +// skip test if external file isn't readable +func readOrSkip(t *testing.T, name string) string { + data, err := ioutil.ReadFile(name) + if err != nil { + t.Skip(err) + } + return string(bytes.TrimSpace(data)) +} + +func TestNewMachine(t *testing.T) { + userID := readOrSkip(t, machineIDPath) + sessionID := readOrSkip(t, bootIDPath) + + c, err := NewMachineClient("https://example.com") + if err != nil { + t.Fatal(err) + } + if c.userID != userID { + t.Errorf("%q != %q", c.userID, userID) + } + if c.sessionID != sessionID { + t.Errorf("%q != %q", c.sessionID, sessionID) + } +} From e67d0ff67a5b3caf31107dc557cf9b2be6ad9661 Mon Sep 17 00:00:00 2001 From: Michael Marineau Date: Thu, 25 May 2017 14:29:35 -0700 Subject: [PATCH 10/10] README: explain implementation status in more detail --- README.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c711287..573622a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,24 @@ # Go Omaha -Implementation of the omaha protocol in Go. +[![Build Status](https://travis-ci.org/coreos/go-omaha.svg?branch=master)](https://travis-ci.org/coreos/go-omaha) +[![GoDoc](https://godoc.org/github.com/coreos/go-omaha/omaha?status.svg)](https://godoc.org/github.com/coreos/go-omaha/omaha) -https://github.com/google/omaha +Implementation of the [omaha update protocol](https://github.com/google/omaha) in Go. -## Docs +## Status -http://godoc.org/github.com/coreos/go-omaha/omaha +This code is targeted for use with CoreOS's [CoreUpdate](https://coreos.com/products/coreupdate/) product and the Container Linux [update_engine](https://github.com/coreos/update_engine). +As a result this is not a complete implementation of the [protocol](https://github.com/google/omaha/blob/master/doc/ServerProtocolV3.md) and inherits a number of quirks from update_engine. +These differences include: -[![Build Status](https://travis-ci.org/coreos/go-omaha.png)](https://travis-ci.org/coreos/go-omaha) + - No offline activity tracking. + The protocol's ping mechanism allows for tracking application usage, reporting the number of days since the last ping and how many of those days saw active usage. + CoreUpdate does not use this, instead assuming update clients are always online and checking in once every ~45-50 minutes. + Each check in should include a ping and optionally an update check. + + - Various protocol extensions/abuses. + update_engine, likely due to earlier limitations of the protocol and Google's server implementation, uses a number of non-standard fields. + For example, packing a lot of extra attributes such as the package's SHA-256 hash into a "postinstall" action. + As much as possible the code includes comments about these extensions. + + - Many data fields not used by CoreUpdate are omitted.