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
129 changes: 89 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Serveradmin is a central server database management system used by InnoGames. Th
- Retrieve server attributes and metadata
- Authenticate using SSH keys or security tokens
- Use as both a library and command-line tool
- Soon: Create and modify server objects
- Create, modify, and delete server objects with change tracking

## Installation

Expand All @@ -26,47 +26,87 @@ The client requires configuration to connect to your Serveradmin instance. Creat

```bash
export SERVERADMIN_BASE_URL="https://your-serveradmin-instance.com"
export SERVERADMIN_AUTH_TOKEN="your-auth-token"
or have a SSH_AUTH_SOCKET available
export SERVERADMIN_TOKEN="your-auth-token"
# or set SERVERADMIN_KEY_PATH to an SSH private key, or have SSH_AUTH_SOCK available
```

These variables are read only by `adminapi.NewClientFromEnv()`. The primary
`NewClient(Config{...})` constructor reads no environment variables.

## Usage

### As a Go Library
### As a Go Library (recommended: explicit Client)

The recommended entry point is an explicit, per-instance `Client` built with
`NewClient(Config{...})`. A `Client` reads no environment variables, holds its
own `*http.Client`, and is safe for concurrent use — so a single process can
serve several targets with different URLs/credentials at once. Every network
call takes a `context.Context`, giving the caller control over cancellation and
timeouts.

```go
package main

import (
"context"
"fmt"
"time"

"github.com/innogames/serveradmin-go-client/adminapi"
)

func main() {
// Create a query
query, err := adminapi.FromQuery("hostname=web*")
// Construct a client with explicit configuration — no env reads, no globals.
client, err := adminapi.NewClient(adminapi.Config{
BaseURL: "https://your-serveradmin-instance.com",
Token: "your-token", // or: SSHSigner / KeyPath for SSH auth
Timeout: 10 * time.Second,
})
if err != nil {
panic(err)
}

// Set attributes to retrieve
query.SetAttributes([]string{"hostname", "ip", "environment"})
ctx := context.Background()

// Execute query
servers, err := query.All()
// Build a query bound to this client.
query, err := client.FromQuery("hostname=web*")
if err != nil {
panic(err)
}
query.SetAttributes("hostname", "intern_ip", "environment")

// Execute the query.
servers, err := query.All(ctx)
if err != nil {
panic(err)
}

// Process results
for _, server := range servers {
hostname := server.Get("hostname")
ip := server.Get("ip")
fmt.Printf("Server: %s (%s)\n", hostname, ip)
fmt.Printf("Server: %s (%s)\n", server.GetString("hostname"), server.GetString("intern_ip"))
}
}
```

Authentication is selected **explicitly** from `Config`, in the order
`SSHSigner` → `KeyPath` → `Token`. There is no ambient environment precedence, so
an inherited `SSH_AUTH_SOCK` can never silently override an explicitly configured
token.

For deployments that are configured entirely through environment variables (for
example the CLI), `adminapi.NewClientFromEnv()` builds a `Client` from the
`SERVERADMIN_*` variables, applying the precedence
`SERVERADMIN_KEY_PATH` → `SSH_AUTH_SOCK` → `SERVERADMIN_TOKEN`.

All entry points hang off a `Client` (`client.NewQuery`, `client.FromQuery`,
`client.NewObject`, `client.CallAPI`) and every network call
(`All`, `One`, `Count`, `Commit`) takes a `context.Context`.

#### Typed attribute getters

`Get` returns `any` and converts JSON numbers to `int` (lossy). When you need to
preserve numeric type, use the typed getters: `GetInt`, `GetFloat`, `GetBool`
(alongside the existing `GetString` and `GetMulti`).

### As a CLI Tool

```bash
Expand Down Expand Up @@ -94,59 +134,68 @@ The client supports Serveradmin's query language for filtering servers:
### SSH Key Authentication (Recommended)

```go
// The client will automatically use SSH keys from:
// - SSH agent
// - ~/.ssh/id_rsa (or other default keys)
// - Path specified in SERVERADMIN_SSH_KEY_PATH
// Explicit client: provide a pre-built signer or a key file path.
client, _ := adminapi.NewClient(adminapi.Config{
BaseURL: "https://your-serveradmin-instance.com",
KeyPath: "/path/to/id_ed25519", // or SSHSigner: <ssh.Signer>
})

// Env path via NewClientFromEnv(): SERVERADMIN_KEY_PATH, or an SSH agent via SSH_AUTH_SOCK.
```

### Security Token Authentication

```go
// Set SERVERADMIN_AUTH_TOKEN environment variable
// or configure in your config file
// Explicit client.
client, _ := adminapi.NewClient(adminapi.Config{
BaseURL: "https://your-serveradmin-instance.com",
Token: "your-token",
})

// Env path via NewClientFromEnv(): set SERVERADMIN_TOKEN.
```

## Examples

### Creating a New Server

```go
// Create a new VM server
newServer, err := adminapi.NewServer("vm")
// NewObject fetches defaults, applies attributes, commits, and re-queries to
// populate object_id — all bound to the client and the provided context.
newServer, err := client.NewObject(ctx, "vm", adminapi.Attributes{
"hostname": "newwebserver",
"environment": "staging",
})
if err != nil {
panic(err)
}

// Set attributes
newServer.Set("hostname", "newwebserver")
newServer.Set("environment", "staging")
newServer.Set("ip", "192.168.1.100")

// Commit to Serveradmin
err = newServer.Commit()
fmt.Printf("Created %s (object_id %d)\n", newServer.GetString("hostname"), newServer.ObjectID())
```

### Modifying Existing Servers

```go
// Find and modify a server
query, _ := adminapi.FromQuery("hostname=webserver01")
server := query.One()
// Find and modify a server.
query, _ := client.FromQuery("hostname=webserver01")
server, err := query.One(ctx)
if err != nil {
panic(err)
}

// Update attributes
server.Set("backup_disabled", "true")
server.Set("maintenance_mode", "true")
// Update attributes.
server.Set("backup_disabled", true)

// Commit changes
server.Commit()
// Commit changes.
if _, err := server.Commit(ctx); err != nil {
panic(err)
}
```

### Calling API Functions

```go
// Call a remote API function by group and function name
result, err := adminapi.CallAPI("ip", "get_free", map[string]any{"network": "internal"})
// Call a remote API function by group and function name.
result, err := client.CallAPI(ctx, "ip", "get_free", map[string]any{"network": "internal"})
if err != nil {
panic(err)
}
Expand Down
7 changes: 4 additions & 3 deletions adminapi/call.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package adminapi

import (
"context"
"encoding/json"
"fmt"
)
Expand All @@ -20,17 +21,17 @@ type callResponse struct {
Message string `json:"message"`
}

// CallAPI calls a remote API function on the Serveradmin server.
// CallAPI calls a remote API function on the Serveradmin server using this client.
// It takes a function group, function name, and keyword arguments as a map.
func CallAPI(group, function string, args map[string]any) (any, error) {
func (c *Client) CallAPI(ctx context.Context, group, function string, args map[string]any) (any, error) {
req := callRequest{
Group: group,
Name: function,
Args: []any{},
Kwargs: args,
}

resp, err := sendRequest(apiEndpointCall, req)
resp, err := c.sendRequest(ctx, apiEndpointCall, req)
if err != nil {
return nil, err
}
Expand Down
25 changes: 9 additions & 16 deletions adminapi/call_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package adminapi

import (
"context"
"encoding/json"
"io"
"net/http"
Expand All @@ -23,11 +24,9 @@ func TestCallAPISuccess(t *testing.T) {
}))
defer server.Close()

resetConfig()
t.Setenv("SERVERADMIN_TOKEN", "testtoken")
t.Setenv("SERVERADMIN_BASE_URL", server.URL)
client := mustClient(t, server.URL)

result, err := CallAPI("ip", "get_free", map[string]any{"network": "internal"})
result, err := client.CallAPI(context.Background(), "ip", "get_free", map[string]any{"network": "internal"})
require.NoError(t, err)
assert.Equal(t, "10.0.0.1", result)

Expand All @@ -45,11 +44,9 @@ func TestCallAPIError(t *testing.T) {
}))
defer server.Close()

resetConfig()
t.Setenv("SERVERADMIN_TOKEN", "testtoken")
t.Setenv("SERVERADMIN_BASE_URL", server.URL)
client := mustClient(t, server.URL)

result, err := CallAPI("ip", "nonexistent", map[string]any{})
result, err := client.CallAPI(context.Background(), "ip", "nonexistent", map[string]any{})
assert.Nil(t, result)
require.Error(t, err)
assert.Contains(t, err.Error(), "ip.nonexistent")
Expand All @@ -63,11 +60,9 @@ func TestCallAPIComplexReturnValue(t *testing.T) {
}))
defer server.Close()

resetConfig()
t.Setenv("SERVERADMIN_TOKEN", "testtoken")
t.Setenv("SERVERADMIN_BASE_URL", server.URL)
client := mustClient(t, server.URL)

result, err := CallAPI("ip", "get_details", map[string]any{"ip": "10.0.0.1"})
result, err := client.CallAPI(context.Background(), "ip", "get_details", map[string]any{"ip": "10.0.0.1"})
require.NoError(t, err)

resultMap, ok := result.(map[string]any)
Expand All @@ -88,11 +83,9 @@ func TestCallAPINilArgs(t *testing.T) {
}))
defer server.Close()

resetConfig()
t.Setenv("SERVERADMIN_TOKEN", "testtoken")
t.Setenv("SERVERADMIN_BASE_URL", server.URL)
client := mustClient(t, server.URL)

result, err := CallAPI("system", "ping", nil)
result, err := client.CallAPI(context.Background(), "system", "ping", nil)
require.NoError(t, err)
assert.Nil(t, result)

Expand Down
91 changes: 91 additions & 0 deletions adminapi/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package adminapi

import (
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"

"golang.org/x/crypto/ssh"
)

// Config holds the explicit, per-instance configuration for a Client.
//
// Authentication is selected explicitly from the fields below, in this order:
// SSHSigner, then KeyPath, then Token. No environment variables are consulted,
// so an ambient SSH_AUTH_SOCK can never override an explicitly configured token.
type Config struct {
// BaseURL is the Serveradmin base URL (required). A trailing "/api" is trimmed.
BaseURL string

// Token enables security-token authentication (HMAC-SHA1).
Token string

// SSHSigner enables SSH-signature authentication using a pre-built signer.
// This takes precedence over KeyPath and Token.
SSHSigner ssh.Signer

// KeyPath is the path to a private key file used for SSH-signature
// authentication. Used only when SSHSigner is nil.
KeyPath string

// HTTPClient is the HTTP client used for all requests. If nil, a dedicated
// client is created using Timeout.
HTTPClient *http.Client

// Timeout is applied to the generated HTTP client. Ignored when HTTPClient
// is provided. A zero value means no timeout.
Timeout time.Duration
}

// Client is a per-instance Serveradmin API client. It carries its own
// configuration and *http.Client and is safe for concurrent use: all fields are
// set once at construction and never mutated afterwards.
type Client struct {
baseURL string
authToken []byte
sshSigner ssh.Signer
httpClient *http.Client
}

// NewClient builds a Client from an explicit Config. It performs no environment
// reads and keeps no global state, so multiple clients with different base URLs
// and credentials can coexist and be used concurrently in the same process.
func NewClient(cfg Config) (*Client, error) {
if cfg.BaseURL == "" {
return nil, errors.New("config: BaseURL is required")
}

c := &Client{
baseURL: strings.TrimSuffix(cfg.BaseURL, "/api"),
}

switch {
case cfg.SSHSigner != nil:
c.sshSigner = cfg.SSHSigner
case cfg.KeyPath != "":
keyBytes, err := os.ReadFile(cfg.KeyPath)
if err != nil {
return nil, fmt.Errorf("failed to read private key from %s: %w", cfg.KeyPath, err)
}
signer, err := ssh.ParsePrivateKey(keyBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}
c.sshSigner = signer
case cfg.Token != "":
c.authToken = []byte(cfg.Token)
default:
return nil, errors.New("config: no authentication method configured: set Token, SSHSigner or KeyPath")
}

if cfg.HTTPClient != nil {
c.httpClient = cfg.HTTPClient
} else {
c.httpClient = &http.Client{Timeout: cfg.Timeout}
}

return c, nil
}
Loading
Loading