diff --git a/client.go b/client.go index 58ca3d7..f06ca1e 100644 --- a/client.go +++ b/client.go @@ -673,6 +673,11 @@ func (c *Client) EndpointAuthPoll(ctx context.Context, pollCode string) (*messag return d, err } +func (c *Client) Downloads(ctx context.Context) (*message.DownloadsData, error) { + _, d, err := callAPI[message.DownloadsData](ctx, c, "GET", message.DownloadsEndpoint, nil) + return d, err +} + func urlPath(base, path string) (string, error) { baseURL, err := url.Parse(base) if err != nil { diff --git a/client_fixtures_test.go b/client_fixtures_test.go new file mode 100644 index 0000000..44af6a0 --- /dev/null +++ b/client_fixtures_test.go @@ -0,0 +1,75 @@ +package dnapi + +const downloadsResponse = `{ + "data": { + "dnclient": { + "0.8.4": { + "freebsd-amd64": "https://dl.defined.net/290ff4b6/v0.8.4/freebsd/amd64/dnclient", + "freebsd-arm64": "https://dl.defined.net/290ff4b6/v0.8.4/freebsd/arm64/dnclient", + "linux-386": "https://dl.defined.net/290ff4b6/v0.8.4/linux/386/dnclient", + "linux-amd64": "https://dl.defined.net/290ff4b6/v0.8.4/linux/amd64/dnclient", + "linux-arm64": "https://dl.defined.net/290ff4b6/v0.8.4/linux/arm64/dnclient", + "linux-armv5": "https://dl.defined.net/290ff4b6/v0.8.4/linux/arm-5/dnclient", + "linux-armv6": "https://dl.defined.net/290ff4b6/v0.8.4/linux/arm-6/dnclient", + "linux-armv7": "https://dl.defined.net/290ff4b6/v0.8.4/linux/arm-7/dnclient", + "linux-mips": "https://dl.defined.net/290ff4b6/v0.8.4/linux/mips/dnclient", + "linux-mips-softfloat": "https://dl.defined.net/290ff4b6/v0.8.4/linux/mips-softfloat/dnclient", + "linux-mips64": "https://dl.defined.net/290ff4b6/v0.8.4/linux/mips64/dnclient", + "linux-mips64le": "https://dl.defined.net/290ff4b6/v0.8.4/linux/mips64le/dnclient", + "linux-mipsle": "https://dl.defined.net/290ff4b6/v0.8.4/linux/mipsle/dnclient", + "linux-ppc64le": "https://dl.defined.net/290ff4b6/v0.8.4/linux/ppc64le/dnclient", + "linux-riscv64": "https://dl.defined.net/290ff4b6/v0.8.4/linux/riscv64/dnclient", + "macos-universal-desktop": "https://dl.defined.net/290ff4b6/v0.8.4/macos/DNClient-Desktop.dmg", + "macos-universal-server": "https://dl.defined.net/290ff4b6/v0.8.4/macos/dnclient", + "macos-universal-server-dmg": "https://dl.defined.net/290ff4b6/v0.8.4/macos/DNClient-Server.dmg", + "windows-amd64-desktop": "https://dl.defined.net/290ff4b6/v0.8.4/windows/amd64/DNClient-Desktop.msi", + "windows-amd64-server": "https://dl.defined.net/290ff4b6/v0.8.4/windows/amd64/DNClient-Server.msi", + "windows-arm64-desktop": "https://dl.defined.net/290ff4b6/v0.8.4/windows/arm64/DNClient-Desktop.msi", + "windows-arm64-server": "https://dl.defined.net/290ff4b6/v0.8.4/windows/arm64/DNClient-Server.msi" + }, + "latest": { + "freebsd-amd64": "https://dl.defined.net/290ff4b6/v0.8.4/freebsd/amd64/dnclient", + "freebsd-arm64": "https://dl.defined.net/290ff4b6/v0.8.4/freebsd/arm64/dnclient", + "linux-386": "https://dl.defined.net/290ff4b6/v0.8.4/linux/386/dnclient", + "linux-amd64": "https://dl.defined.net/290ff4b6/v0.8.4/linux/amd64/dnclient", + "linux-arm64": "https://dl.defined.net/290ff4b6/v0.8.4/linux/arm64/dnclient", + "linux-armv5": "https://dl.defined.net/290ff4b6/v0.8.4/linux/arm-5/dnclient", + "linux-armv6": "https://dl.defined.net/290ff4b6/v0.8.4/linux/arm-6/dnclient", + "linux-armv7": "https://dl.defined.net/290ff4b6/v0.8.4/linux/arm-7/dnclient", + "linux-mips": "https://dl.defined.net/290ff4b6/v0.8.4/linux/mips/dnclient", + "linux-mips-softfloat": "https://dl.defined.net/290ff4b6/v0.8.4/linux/mips-softfloat/dnclient", + "linux-mips64": "https://dl.defined.net/290ff4b6/v0.8.4/linux/mips64/dnclient", + "linux-mips64le": "https://dl.defined.net/290ff4b6/v0.8.4/linux/mips64le/dnclient", + "linux-mipsle": "https://dl.defined.net/290ff4b6/v0.8.4/linux/mipsle/dnclient", + "linux-ppc64le": "https://dl.defined.net/290ff4b6/v0.8.4/linux/ppc64le/dnclient", + "linux-riscv64": "https://dl.defined.net/290ff4b6/v0.8.4/linux/riscv64/dnclient", + "macos-universal-desktop": "https://dl.defined.net/290ff4b6/v0.8.4/macos/DNClient-Desktop.dmg", + "macos-universal-server": "https://dl.defined.net/290ff4b6/v0.8.4/macos/dnclient", + "macos-universal-server-dmg": "https://dl.defined.net/290ff4b6/v0.8.4/macos/DNClient-Server.dmg", + "windows-amd64-desktop": "https://dl.defined.net/290ff4b6/v0.8.4/windows/amd64/DNClient-Desktop.msi", + "windows-amd64-server": "https://dl.defined.net/290ff4b6/v0.8.4/windows/amd64/DNClient-Server.msi", + "windows-arm64-desktop": "https://dl.defined.net/290ff4b6/v0.8.4/windows/arm64/DNClient-Desktop.msi", + "windows-arm64-server": "https://dl.defined.net/290ff4b6/v0.8.4/windows/arm64/DNClient-Server.msi" + } + }, + "mobile": { + "android": "https://play.google.com/store/apps/details?id=net.defined.mobile_nebula", + "ios": "https://apps.apple.com/us/app/mobile-nebula/id1509587936" + }, + "container": { + "docker": "https://hub.docker.com/r/definednet/dnclient/" + }, + "versionInfo": { + "dnclient": { + "0.8.4": { + "latest": true, + "releaseDate": "2025-10-10" + } + }, + "latest": { + "dnclient": "0.8.4", + "mobile": "0.5.1" + } + } + } +}` diff --git a/client_test.go b/client_test.go index ab662df..b47c170 100644 --- a/client_test.go +++ b/client_test.go @@ -1156,3 +1156,59 @@ func TestDoOidcPoll(t *testing.T) { assert.Empty(t, ts.Errors()) assert.Equal(t, 0, ts.RequestsRemaining()) } + +func TestDownloads(t *testing.T) { + t.Parallel() + + useragent := "dnclientUnitTests/1.0.0 (not a real client)" + ts := dnapitest.NewServer(useragent) + client := NewClient(useragent, ts.URL) + t.Cleanup(func() { ts.Close() }) + + // Happy path - successful downloads response + ts.ExpectAPIRequest(http.StatusOK, func(r any) []byte { + return []byte(downloadsResponse) + }) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + resp, err := client.Downloads(ctx) + require.NoError(t, err) + assert.Empty(t, ts.Errors()) + assert.Equal(t, 0, ts.RequestsRemaining()) + + // Verify DNClient downloads - this list is not exhaustive but tests some of the more common platforms. + require.NotNil(t, resp) + require.Contains(t, resp.DNClient, "0.8.4") + assert.Equal(t, "https://dl.defined.net/290ff4b6/v0.8.4/freebsd/amd64/dnclient", resp.DNClient["0.8.4"]["freebsd-amd64"]) + assert.Equal(t, "https://dl.defined.net/290ff4b6/v0.8.4/freebsd/arm64/dnclient", resp.DNClient["0.8.4"]["freebsd-arm64"]) + assert.Equal(t, "https://dl.defined.net/290ff4b6/v0.8.4/linux/amd64/dnclient", resp.DNClient["0.8.4"]["linux-amd64"]) + assert.Equal(t, "https://dl.defined.net/290ff4b6/v0.8.4/linux/arm64/dnclient", resp.DNClient["0.8.4"]["linux-arm64"]) + assert.Equal(t, "https://dl.defined.net/290ff4b6/v0.8.4/windows/amd64/DNClient-Desktop.msi", resp.DNClient["0.8.4"]["windows-amd64-desktop"]) + assert.Equal(t, "https://dl.defined.net/290ff4b6/v0.8.4/windows/amd64/DNClient-Server.msi", resp.DNClient["0.8.4"]["windows-amd64-server"]) + assert.Equal(t, "https://dl.defined.net/290ff4b6/v0.8.4/windows/arm64/DNClient-Desktop.msi", resp.DNClient["0.8.4"]["windows-arm64-desktop"]) + assert.Equal(t, "https://dl.defined.net/290ff4b6/v0.8.4/windows/arm64/DNClient-Server.msi", resp.DNClient["0.8.4"]["windows-arm64-server"]) + assert.Equal(t, "https://dl.defined.net/290ff4b6/v0.8.4/macos/dnclient", resp.DNClient["0.8.4"]["macos-universal-server"]) + assert.Equal(t, "https://dl.defined.net/290ff4b6/v0.8.4/macos/DNClient-Server.dmg", resp.DNClient["0.8.4"]["macos-universal-server-dmg"]) + assert.Equal(t, "https://dl.defined.net/290ff4b6/v0.8.4/macos/DNClient-Desktop.dmg", resp.DNClient["0.8.4"]["macos-universal-desktop"]) + + // Verify the latest release info + require.Contains(t, resp.DNClient, "latest") + require.Equal(t, resp.DNClient["latest"], resp.DNClient["0.8.4"]) + + // Verify mobile downloads + assert.Equal(t, "https://play.google.com/store/apps/details?id=net.defined.mobile_nebula", resp.Mobile.Android) + assert.Equal(t, "https://apps.apple.com/us/app/mobile-nebula/id1509587936", resp.Mobile.IOS) + + // Verify container downloads + assert.Equal(t, "https://hub.docker.com/r/definednet/dnclient/", resp.Container.Docker) + + // Verify version info + require.Contains(t, resp.VersionInfo.DNClient, "0.8.4") + assert.True(t, resp.VersionInfo.DNClient["0.8.4"].Latest) + assert.Equal(t, "2025-10-10", resp.VersionInfo.DNClient["0.8.4"].ReleaseDate) + + // Verify latest versions + assert.Equal(t, "0.8.4", resp.VersionInfo.Latest.DNClient) + assert.Equal(t, "0.5.1", resp.VersionInfo.Latest.Mobile) +} diff --git a/dnapitest/dnapitest.go b/dnapitest/dnapitest.go index 249add5..4cb4677 100644 --- a/dnapitest/dnapitest.go +++ b/dnapitest/dnapitest.go @@ -74,6 +74,11 @@ func (s *Server) handler(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(expected.Respond(nil)) case message.AuthPollEndpoint: s.handlerDoOidcPoll(w, r) + case message.DownloadsEndpoint: + expected := s.expectedRequests[0] + s.expectedRequests = s.expectedRequests[1:] + w.WriteHeader(expected.StatusCode()) + _, _ = w.Write(expected.Respond(nil)) default: s.errors = append(s.errors, fmt.Errorf("invalid request path %s", r.URL.Path)) http.NotFound(w, r) diff --git a/message/message.go b/message/message.go index dab9c90..99cee3f 100644 --- a/message/message.go +++ b/message/message.go @@ -246,6 +246,46 @@ type EndpointAuthPollData struct { EnrollmentCode string `json:"enrollmentCode"` } +const DownloadsEndpoint = "/v1/downloads" + +type DownloadsData struct { + // DNClient maps versions to a map of platforms' download links. + DNClient map[string]map[string]string `json:"dnclient"` + // Mobile maps platforms to their download links (i.e. App Store / Play Store.) + Mobile DownloadsMobile `json:"mobile"` + // Links to container repositories like Docker + Container DownloadsContainers `json:"container"` + + // VersionInfo contains information about past versions. + VersionInfo DownloadsVersionInfo `json:"versionInfo"` +} + +type DownloadsVersionInfo struct { + // DNClient maps versions to their version info. + DNClient map[string]DNClientVersionInfo `json:"dnclient"` + // Latest returns the latest versions for each platform. + Latest DownloadsLatest `json:"latest"` +} + +type DownloadsMobile struct { + Android string `json:"android"` + IOS string `json:"ios"` +} + +type DownloadsContainers struct { + Docker string `json:"docker"` +} + +type DownloadsLatest struct { + DNClient string `json:"dnclient"` + Mobile string `json:"mobile"` +} + +type DNClientVersionInfo struct { + Latest bool `json:"latest"` + ReleaseDate string `json:"releaseDate"` +} + // NetworkCurve represents the network curve specified by the API. type NetworkCurve string