Skip to content
Open
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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Different versions of the API client support different features depending on you
| **midclt CLI** |
| stdin payload (`-` argument) | ❌ | ❌ | ❌ | ✅ |
| `--insecure` CLI flag | ❌ | ❌ | ❌ | ✅ |
| Persistent daemon mode (experimental) | ❌ | ❌ | ❌ | ✅ |
| **Python API** |
| New-style jobs support | ❌ | ❌ | ✅ | ✅ |

Expand Down Expand Up @@ -61,6 +62,7 @@ Different versions of the API client support different features depending on you
- **midclt:** API key file path support - `-K` can now accept file paths directly
- **midclt:** `--insecure` flag for development/testing with self-signed certificates
- **midclt:** stdin processing for sensitive payloads (`-` argument)
- **midclt:** Persistent daemon mode (experimental) for reduced authentication overhead

## Getting Started

Expand All @@ -80,6 +82,34 @@ The client's default behavior is to connect to the localhost's middlewared socke

**Note on Performance:** Each `midclt` command invocation incurs significant authentication and auditing overhead. Workloads that poll API endpoints or call endpoints frequently should use the Python API client directly with a persistent authenticated websocket connection (see [Scripting](#scripting) section below) rather than repeatedly invoking `midclt`.

#### Persistent Daemon Mode (Experimental)

**EXPERIMENTAL: This feature is under active development and may change.**

To reduce authentication overhead for frequent `midclt` invocations, you can run a persistent daemon that maintains an authenticated connection. Subsequent `midclt` calls use the `-d` flag to communicate with the daemon via Unix domain socket.

```bash
# Start daemon in background (authenticates once, logs to ~/.midclt/logs/)
midclt -u wss://192.168.1.108/api/current -U admin -K /path/to/key.json daemon --lifetime 600 &

# Use daemon for calls (no re-authentication needed, but URI/username required to find socket)
midclt -u wss://192.168.1.108/api/current -U admin -d call system.info
midclt -u wss://192.168.1.108/api/current -U admin -d -j call pool.dataset.lock mypool/mydataset

# Stop daemon
midclt -u wss://192.168.1.108/api/current -U admin daemon-stop
```

**Daemon Options:**
- `--lifetime SECONDS`: Idle timeout before daemon exits (default: 600, 0 = no timeout)
- `--log-file PATH`: Log to file instead of default (`~/.midclt/logs/daemon-{id}.log`)

**Notes:**
- Daemon uses Unix domain sockets (Linux/macOS/BSD only)
- Each URI/username combination gets a unique daemon instance
- Socket and logs stored in `~/.midclt/`
- Subscription commands not supported via daemon

#### Disable SSL certificate verification

The `--insecure` option disables SSL certificate verification when connecting to a remote TrueNAS instance. This is useful for development or testing environments with self-signed certificates.
Expand Down
1 change: 1 addition & 0 deletions tests/daemon/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for daemon functionality."""
239 changes: 239 additions & 0 deletions tests/daemon/test_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
"""Unit tests for daemon client message parsing."""

import json
import unittest
from unittest.mock import patch

from truenas_api_client.daemon.client import send_to_daemon
from truenas_api_client.daemon.constants import MessageType, Command


class MockSocket:
"""Mock socket that returns predefined responses."""

def __init__(self, responses):
"""Initialize with list of response strings to return."""
self.responses = responses
self.response_index = 0
self.sent_data = []
self.closed = False

def connect(self, path):
"""Mock connect."""
pass

def settimeout(self, timeout):
"""Mock settimeout."""
pass

def sendall(self, data):
"""Mock sendall - record what was sent."""
self.sent_data.append(data)

def recv(self, bufsize):
"""Mock recv - return next chunk of response."""
if self.response_index < len(self.responses):
data = self.responses[self.response_index]
self.response_index += 1
return data
return b'' # EOF

def close(self):
"""Mock close."""
self.closed = True


class TestMessageParsing(unittest.TestCase):
"""Test message parsing logic."""

@patch('truenas_api_client.daemon.client.socket.socket')
@patch('truenas_api_client.daemon.client.get_daemon_socket_path')
def test_simple_result_message(self, mock_path, mock_socket_class):
"""Test parsing a simple result message."""
response = {'type': MessageType.RESULT, 'result': 'pong'}
response_bytes = (json.dumps(response) + '\n').encode('utf-8')

mock_sock = MockSocket([response_bytes])
mock_socket_class.return_value = mock_sock
mock_path.return_value = '/tmp/test.sock'

result = send_to_daemon({'command': Command.PING})

self.assertEqual(result['type'], MessageType.RESULT)
self.assertEqual(result['result'], 'pong')
self.assertTrue(mock_sock.closed)

@patch('truenas_api_client.daemon.client.socket.socket')
@patch('truenas_api_client.daemon.client.get_daemon_socket_path')
def test_error_message(self, mock_path, mock_socket_class):
"""Test parsing an error message."""
response = {
'type': MessageType.ERROR,
'error': 'Something went wrong',
'error_type': 'ValueError'
}
response_bytes = (json.dumps(response) + '\n').encode('utf-8')

mock_sock = MockSocket([response_bytes])
mock_socket_class.return_value = mock_sock
mock_path.return_value = '/tmp/test.sock'

result = send_to_daemon({'command': Command.CALL})

self.assertEqual(result['type'], MessageType.ERROR)
self.assertEqual(result['error'], 'Something went wrong')
self.assertTrue(mock_sock.closed)

@patch('truenas_api_client.daemon.client.socket.socket')
@patch('truenas_api_client.daemon.client.get_daemon_socket_path')
def test_progress_then_result(self, mock_path, mock_socket_class):
"""Test parsing progress messages followed by result."""
progress1 = {
'type': MessageType.PROGRESS,
'percent': 50,
'description': 'Halfway there'
}
progress2 = {
'type': MessageType.PROGRESS,
'percent': 100,
'description': 'Complete'
}
result = {'type': MessageType.RESULT, 'result': True}

# Send as separate chunks
responses = [
(json.dumps(progress1) + '\n').encode('utf-8'),
(json.dumps(progress2) + '\n').encode('utf-8'),
(json.dumps(result) + '\n').encode('utf-8'),
]

mock_sock = MockSocket(responses)
mock_socket_class.return_value = mock_sock
mock_path.return_value = '/tmp/test.sock'

progress_updates = []

def progress_callback(msg):
progress_updates.append(msg)

final_result = send_to_daemon(
{'command': Command.CALL},
progress_callback=progress_callback
)

# Should have received 2 progress updates
self.assertEqual(len(progress_updates), 2)
self.assertEqual(progress_updates[0]['percent'], 50)
self.assertEqual(progress_updates[1]['percent'], 100)

# Final result should be the result message
self.assertEqual(final_result['type'], MessageType.RESULT)
self.assertEqual(final_result['result'], True)

@patch('truenas_api_client.daemon.client.socket.socket')
@patch('truenas_api_client.daemon.client.get_daemon_socket_path')
def test_progress_without_callback(self, mock_path, mock_socket_class):
"""Test that progress messages are skipped when no callback provided."""
progress = {
'type': MessageType.PROGRESS,
'percent': 50,
'description': 'Halfway'
}
result = {'type': MessageType.RESULT, 'result': 'done'}

responses = [
(json.dumps(progress) + '\n').encode('utf-8'),
(json.dumps(result) + '\n').encode('utf-8'),
]

mock_sock = MockSocket(responses)
mock_socket_class.return_value = mock_sock
mock_path.return_value = '/tmp/test.sock'

# No callback provided - progress should be skipped
final_result = send_to_daemon({'command': Command.CALL})

self.assertEqual(final_result['type'], MessageType.RESULT)
self.assertEqual(final_result['result'], 'done')

@patch('truenas_api_client.daemon.client.socket.socket')
@patch('truenas_api_client.daemon.client.get_daemon_socket_path')
def test_chunked_message(self, mock_path, mock_socket_class):
"""Test parsing a message received in multiple chunks."""
result = {'type': MessageType.RESULT, 'result': 'test'}
result_str = json.dumps(result) + '\n'
result_bytes = result_str.encode('utf-8')

# Split into multiple chunks
chunk1 = result_bytes[:10]
chunk2 = result_bytes[10:20]
chunk3 = result_bytes[20:]

mock_sock = MockSocket([chunk1, chunk2, chunk3])
mock_socket_class.return_value = mock_sock
mock_path.return_value = '/tmp/test.sock'

final_result = send_to_daemon({'command': Command.PING})

self.assertEqual(final_result['type'], MessageType.RESULT)
self.assertEqual(final_result['result'], 'test')

@patch('truenas_api_client.daemon.client.socket.socket')
@patch('truenas_api_client.daemon.client.get_daemon_socket_path')
def test_multiple_messages_in_one_chunk(self, mock_path, mock_socket_class):
"""Test parsing when multiple messages arrive in one chunk."""
progress = {'type': MessageType.PROGRESS, 'percent': 50}
result = {'type': MessageType.RESULT, 'result': 'done'}

# Both messages in one chunk
combined = (json.dumps(progress) + '\n' + json.dumps(result) + '\n').encode('utf-8')

mock_sock = MockSocket([combined])
mock_socket_class.return_value = mock_sock
mock_path.return_value = '/tmp/test.sock'

progress_updates = []

final_result = send_to_daemon(
{'command': Command.CALL},
progress_callback=lambda m: progress_updates.append(m)
)

# Should have parsed both messages correctly
self.assertEqual(len(progress_updates), 1)
self.assertEqual(final_result['type'], MessageType.RESULT)

@patch('truenas_api_client.daemon.client.socket.socket')
@patch('truenas_api_client.daemon.client.get_daemon_socket_path')
def test_unknown_message_type(self, mock_path, mock_socket_class):
"""Test handling of unknown message type."""
response = {'type': 'unknown_type', 'data': 'something'}
response_bytes = (json.dumps(response) + '\n').encode('utf-8')

mock_sock = MockSocket([response_bytes])
mock_socket_class.return_value = mock_sock
mock_path.return_value = '/tmp/test.sock'

with self.assertRaises(Exception) as ctx:
send_to_daemon({'command': Command.PING})

self.assertIn('Unknown message type', str(ctx.exception))

@patch('truenas_api_client.daemon.client.socket.socket')
@patch('truenas_api_client.daemon.client.get_daemon_socket_path')
def test_connection_closed_prematurely(self, mock_path, mock_socket_class):
"""Test handling when daemon closes connection without sending response."""
# Return empty bytes immediately (connection closed)
mock_sock = MockSocket([b''])
mock_socket_class.return_value = mock_sock
mock_path.return_value = '/tmp/test.sock'

with self.assertRaises(Exception) as ctx:
send_to_daemon({'command': Command.PING})

self.assertIn('closed connection without response', str(ctx.exception))


if __name__ == '__main__':
unittest.main()
75 changes: 75 additions & 0 deletions tests/daemon/test_constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
"""Unit tests for daemon constants."""

import unittest

from truenas_api_client.daemon.constants import (
Command,
MessageType,
DAEMON_ACCEPT_TIMEOUT,
DAEMON_CONNECT_TIMEOUT,
DAEMON_REQUEST_TIMEOUT,
SOCKET_PERMISSIONS,
SOCKET_RECV_BUFFER_SIZE,
)


class TestMessageTypeEnum(unittest.TestCase):
"""Test MessageType enum."""

def test_enum_values(self):
"""MessageType enum should have expected values."""
self.assertEqual(MessageType.PROGRESS, 'progress')
self.assertEqual(MessageType.RESULT, 'result')
self.assertEqual(MessageType.ERROR, 'error')


class TestCommandEnum(unittest.TestCase):
"""Test Command enum."""

def test_enum_values(self):
"""Command enum should have expected values."""
self.assertEqual(Command.PING, 'ping')
self.assertEqual(Command.CALL, 'call')
self.assertEqual(Command.STOP, 'stop')


class TestTimeoutConstants(unittest.TestCase):
"""Test timeout constants."""

def test_request_timeout(self):
"""Request timeout should be reasonable."""
self.assertGreater(DAEMON_REQUEST_TIMEOUT, 0)
self.assertGreaterEqual(DAEMON_REQUEST_TIMEOUT, 60) # At least 1 minute

def test_accept_timeout(self):
"""Accept timeout should be short for quick checks."""
self.assertGreater(DAEMON_ACCEPT_TIMEOUT, 0)
self.assertLessEqual(DAEMON_ACCEPT_TIMEOUT, 10)

def test_connect_timeout(self):
"""Connect timeout should be short for quick checks."""
self.assertGreater(DAEMON_CONNECT_TIMEOUT, 0)
self.assertLessEqual(DAEMON_CONNECT_TIMEOUT, 5)


class TestBufferSize(unittest.TestCase):
"""Test buffer size constant."""

def test_power_of_two(self):
"""Buffer size should be power of 2 (common for network buffers)."""
# Check if power of 2: n & (n-1) == 0 for powers of 2
# This also ensures it's positive
self.assertEqual(SOCKET_RECV_BUFFER_SIZE & (SOCKET_RECV_BUFFER_SIZE - 1), 0)


class TestSocketPermissions(unittest.TestCase):
"""Test socket permissions constant."""

def test_owner_only(self):
"""Socket permissions should be owner-only (0o600)."""
self.assertEqual(SOCKET_PERMISSIONS, 0o600)


if __name__ == '__main__':
unittest.main()
Loading