Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ jobs:

steps:
- name: Check out ${{ github.repository }}
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Check out Devolutions/actions
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: Devolutions/actions
ref: v1
Expand Down
9 changes: 4 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:

steps:
- name: Check out ${{ github.repository }}
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Check out Devolutions/actions
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: Devolutions/actions
ref: v1
Expand All @@ -37,10 +37,9 @@ jobs:
tag: v${{ steps.get-version.outputs.version }}

- name: Setup Go environment
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: '1.20'
check-latest: true
go-version: ${{ vars.GO_VERSION }}

- name: Download CA certificate
uses: ./.github/workflows/create-file-from-secret
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.DS_Store
.vscode
*.test
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

:warning: **This client is a work in progress, expect breaking changes between releases** :warning:

## Compatibility

| go-dvls version | DVLS version |
|-----------------|----------------|
| 0.16.0+ | 2026.x |
| 0.15.0 | 2024.x, 2025.x |

Heavily based on the information found on the [Devolutions.Server](https://github.com/Devolutions/devolutions-server/tree/main/Powershell%20Module/Devolutions.Server) powershell module.

## Usage
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.15.0
0.16.0
15 changes: 8 additions & 7 deletions authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,11 @@ func NewClient(appKey string, appSecret string, baseUri string) (Client, error)
client.common.client = &client

client.Entries = &Entries{
Credential: (*EntryCredentialService)(&client.common),
Certificate: (*EntryCertificateService)(&client.common),
Website: (*EntryWebsiteService)(&client.common),
Credential: (*EntryCredentialService)(&client.common),
Folder: (*EntryFolderService)(&client.common),
Host: (*EntryHostService)(&client.common),
Website: (*EntryWebsiteService)(&client.common),
}
client.Vaults = (*Vaults)(&client.common)

Expand All @@ -83,18 +84,18 @@ func (c *Client) loginWithContext(ctx context.Context) error {

reqUrl, err := url.JoinPath(c.baseUri, loginEndpoint)
if err != nil {
return fmt.Errorf("failed to build login url. error: %w", err)
return fmt.Errorf("failed to build login url: %w", err)
}

resp, err := c.rawRequestWithContext(ctx, reqUrl, http.MethodPost, loginContentType, bytes.NewBufferString(loginBody))
if err != nil {
return fmt.Errorf("error while submitting login request. error: %w", err)
return fmt.Errorf("error while submitting login request: %w", err)
}

var loginResponse loginResponse
err = json.Unmarshal(resp.Response, &loginResponse)
if err != nil {
return fmt.Errorf("failed to unmarshal response body. error: %w", err)
return fmt.Errorf("failed to unmarshal response body: %w", err)
}

c.credential.token = loginResponse.TokenId
Expand All @@ -109,12 +110,12 @@ func (c *Client) isLogged() (bool, error) {
func (c *Client) isLoggedWithContext(ctx context.Context) (bool, error) {
reqUrl, err := url.JoinPath(c.baseUri, isLoggedEndpoint)
if err != nil {
return false, fmt.Errorf("failed to build isLogged url. error: %w", err)
return false, fmt.Errorf("failed to build isLogged url: %w", err)
}

resp, err := c.rawRequestWithContext(ctx, reqUrl, http.MethodGet, defaultContentType, nil)
if err != nil && !strings.Contains(err.Error(), "json: cannot unmarshal bool into Go value") {
return false, fmt.Errorf("error while submitting isLogged request. error: %w", err)
return false, fmt.Errorf("error while submitting isLogged request: %w", err)
}

if string(resp.Response) == "false" {
Expand Down
12 changes: 6 additions & 6 deletions dvls.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ func (c *Client) Request(url string, reqMethod string, reqBody io.Reader, option
func (c *Client) RequestWithContext(ctx context.Context, url string, reqMethod string, reqBody io.Reader, options ...RequestOptions) (Response, error) {
islogged, err := c.isLoggedWithContext(ctx)
if err != nil {
return Response{}, &RequestError{Err: fmt.Errorf("failed to fetch login status. error: %w", err), Url: url}
return Response{}, &RequestError{Err: fmt.Errorf("failed to fetch login status: %w", err), Url: url}
}
if !islogged {
err := c.loginWithContext(ctx)
if err != nil {
return Response{}, &RequestError{Err: fmt.Errorf("failed to refresh login token. error: %w", err), Url: url}
return Response{}, &RequestError{Err: fmt.Errorf("failed to refresh login token: %w", err), Url: url}
}
}

Expand All @@ -89,15 +89,15 @@ func (c *Client) rawRequestWithContext(ctx context.Context, url string, reqMetho

req, err := http.NewRequestWithContext(ctx, reqMethod, url, reqBody)
if err != nil {
return Response{}, &RequestError{Err: fmt.Errorf("failed to make request. error: %w", err), Url: url}
return Response{}, &RequestError{Err: fmt.Errorf("failed to make request: %w", err), Url: url}
}

req.Header.Add("Content-Type", contentType)
req.Header.Add("tokenId", c.credential.token)

resp, err := c.client.Do(req)
if err != nil {
return Response{}, &RequestError{Err: fmt.Errorf("error while submitting request. error: %w", err), Url: url}
return Response{}, &RequestError{Err: fmt.Errorf("error while submitting request: %w", err), Url: url}
}
defer resp.Body.Close()

Expand All @@ -110,13 +110,13 @@ func (c *Client) rawRequestWithContext(ctx context.Context, url string, reqMetho
var response Response
response.Response, err = io.ReadAll(resp.Body)
if err != nil {
return Response{}, &RequestError{Err: fmt.Errorf("failed to read response body. error: %w", err), Url: url}
return Response{}, &RequestError{Err: fmt.Errorf("failed to read response body: %w", err), Url: url}
}

if !opts.RawBody && len(response.Response) > 0 {
err = json.Unmarshal(response.Response, &response)
if err != nil {
return response, &RequestError{Err: fmt.Errorf("failed to unmarshal response body. error: %w", err), Url: url}
return response, &RequestError{Err: fmt.Errorf("failed to unmarshal response body: %w", err), Url: url}
}
}

Expand Down
4 changes: 2 additions & 2 deletions dvls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import (

var (
testClient Client
testVaultId string
testVaultId string // Used by legacy tests (certificate, host, website)
)

func TestMain(m *testing.M) {
testVaultId = os.Getenv("TEST_VAULT_ID")
testVaultId = os.Getenv("TEST_VAULT_ID") // Optional, only for legacy tests

err := setupTestClient()
if err != nil {
Expand Down
15 changes: 0 additions & 15 deletions dvlstypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,21 +226,6 @@ const (
ServerConnectionSubTypeAppleSafari ServerConnectionSubType = "Safari"
)

type VaultVisibility int

const (
VaultVisibilityDefault VaultVisibility = 0
VaultVisibilityPublic VaultVisibility = 2
VaultVisibilityPrivate VaultVisibility = 3
)

type VaultSecurityLevel int

const (
VaultSecurityLevelStandard VaultSecurityLevel = 0
VaultSecurityLevelHigh VaultSecurityLevel = 1
)

type EntryCertificateDataMode int

const (
Expand Down
132 changes: 131 additions & 1 deletion entries.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package dvls

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
)

Expand All @@ -13,11 +17,28 @@ const (
entryPublicEndpoint string = "/api/v1/vault/{vaultId}/entry/{id}"
)

// ErrUnsupportedEntryType is returned when an entry type/subtype is not supported by this client.
type ErrUnsupportedEntryType struct {
Type string
SubType string
}

func (e ErrUnsupportedEntryType) Error() string {
return fmt.Sprintf("unsupported entry type/subtype: %s/%s", e.Type, e.SubType)
}

// IsUnsupportedEntryType returns true if the error is an ErrUnsupportedEntryType.
func IsUnsupportedEntryType(err error) bool {
var unsupportedErr ErrUnsupportedEntryType
return errors.As(err, &unsupportedErr)
}

type Entries struct {
Certificate *EntryCertificateService
Host *EntryHostService
Credential *EntryCredentialService
Website *EntryWebsiteService
Folder *EntryFolderService
}

type Entry struct {
Expand Down Expand Up @@ -55,6 +76,35 @@ var entryFactories = map[string]func() EntryData{
"Credential/ConnectionString": func() EntryData { return &EntryCredentialConnectionStringData{} },
"Credential/Default": func() EntryData { return &EntryCredentialDefaultData{} },
"Credential/PrivateKey": func() EntryData { return &EntryCredentialPrivateKeyData{} },
"Folder/Company": func() EntryData { return &EntryFolderData{} },
"Folder/Credentials": func() EntryData { return &EntryFolderData{} },
"Folder/Customer": func() EntryData { return &EntryFolderData{} },
"Folder/Database": func() EntryData { return &EntryFolderData{} },
"Folder/Device": func() EntryData { return &EntryFolderData{} },
"Folder/Domain": func() EntryData { return &EntryFolderData{} },
"Folder/Folder": func() EntryData { return &EntryFolderData{} },
"Folder/Identity": func() EntryData { return &EntryFolderData{} },
"Folder/MacroScriptTools": func() EntryData { return &EntryFolderData{} },
"Folder/Printer": func() EntryData { return &EntryFolderData{} },
"Folder/Server": func() EntryData { return &EntryFolderData{} },
"Folder/Site": func() EntryData { return &EntryFolderData{} },
"Folder/SmartFolder": func() EntryData { return &EntryFolderData{} },
"Folder/Software": func() EntryData { return &EntryFolderData{} },
"Folder/Team": func() EntryData { return &EntryFolderData{} },
"Folder/Workstation": func() EntryData { return &EntryFolderData{} },
}

// getSupportedSubTypes extracts all supported subtypes for a given entry type from entryFactories.
// This ensures a single source of truth for supported entry types/subtypes.
func getSupportedSubTypes(entryType string) map[string]struct{} {
result := make(map[string]struct{})
prefix := entryType + "/"
for key := range entryFactories {
if subType, found := strings.CutPrefix(key, prefix); found {
result[subType] = struct{}{}
}
}
return result
}

func (e *Entry) UnmarshalJSON(data []byte) error {
Expand All @@ -73,7 +123,7 @@ func (e *Entry) UnmarshalJSON(data []byte) error {
key := fmt.Sprintf("%s/%s", raw.Type, raw.SubType)
factory, ok := entryFactories[key]
if !ok {
return fmt.Errorf("unsupported entry type/subtype: %s", key)
return ErrUnsupportedEntryType{Type: raw.Type, SubType: raw.SubType}
}

dataStruct := factory()
Expand Down Expand Up @@ -112,3 +162,83 @@ func entryPublicBaseEndpointReplacer(vaultId string) string {
replacer := strings.NewReplacer("{vaultId}", vaultId)
return replacer.Replace(entryBasePublicEndpoint)
}

// entryListRawResponse represents the raw paginated response from the entry list endpoint.
type entryListRawResponse struct {
Data []json.RawMessage `json:"data"`
CurrentPage int `json:"currentPage"`
PageSize int `json:"pageSize"`
TotalCount int `json:"totalCount"`
TotalPage int `json:"totalPage"`
}

// getEntriesOptions contains optional filters for listing entries.
type getEntriesOptions struct {
Name string
Path string
}

// getEntries returns a list of entries from a vault with optional filters.
// Entries with unsupported types are skipped.
// This function handles pagination automatically and returns all entries across all pages.
func (c *Client) getEntries(ctx context.Context, vaultId string, opts getEntriesOptions) ([]Entry, error) {
if vaultId == "" {
return nil, fmt.Errorf("vaultId is required")
}

baseEndpoint := entryPublicBaseEndpointReplacer(vaultId)
reqUrl, err := url.JoinPath(c.baseUri, baseEndpoint)
if err != nil {
return nil, fmt.Errorf("failed to build entry url: %w", err)
}

parsedUrl, err := url.Parse(reqUrl)
if err != nil {
return nil, fmt.Errorf("failed to parse entry url: %w", err)
}

var allEntries []Entry
currentPage := 1

for {
q := parsedUrl.Query()
if opts.Name != "" {
q.Set("name", opts.Name)
}
if opts.Path != "" {
q.Set("path", opts.Path)
}
q.Set("page", fmt.Sprintf("%d", currentPage))
parsedUrl.RawQuery = q.Encode()

resp, err := c.RequestWithContext(ctx, parsedUrl.String(), http.MethodGet, nil)
if err != nil {
return nil, fmt.Errorf("error while fetching entries (page %d): %w", currentPage, err)
}

var rawResp entryListRawResponse
if err := json.Unmarshal(resp.Response, &rawResp); err != nil {
return nil, fmt.Errorf("failed to unmarshal entry list response (page %d): %w", currentPage, err)
}

for _, raw := range rawResp.Data {
var entry Entry
if err := json.Unmarshal(raw, &entry); err != nil {
if IsUnsupportedEntryType(err) {
continue
}
return nil, fmt.Errorf("failed to unmarshal entry (page %d): %w", currentPage, err)
}
entry.VaultId = vaultId
allEntries = append(allEntries, entry)
}

// Check if we've fetched all pages
if currentPage >= rawResp.TotalPage {
break
}
currentPage++
}

return allEntries, nil
}
Loading