"""
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License,
or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Copyright © 2019 Cloud Linux Software Inc.
This software is also available under ImunifyAV commercial license,
see
Message Protocol for Filesystem-Based Storage Requests
This module defines the request/response format for file operations
that are passed through the shared volume.
Flow:
1. Python agent writes .request.json to shared volume
2. Python agent publishes a best-effort NATS doorbell
(imunify360.v1.storage.request.)
3. The syncer's storage subscriber picks up the request
4. Handler executes action, writes .response.json to shared volume
5. Python agent reads response (filename is predictable)
All operations include application_id (from MalwareHit.user field)
Batch Operations:
- Requests and responses are always lists of operations
- Single operations are wrapped in a one-element list
- This allows efficient batch processing of multiple files
"""
import os
from typing import Literal, TypedDict, Optional, List
class BaseOperation(TypedDict):
"""Base structure for a single operation within a batch"""
application_id: str
class StoreOperation(BaseOperation):
"""
Single store operation in a batch.
The application side handler should:
1. Read file from source_path in application storage
2. Write it to shared quarantine volume with storage_name
3. Delete original file from application storage
"""
source_path: str # Path in application (e.g., /var/www/html/malware.php)
storage_name: str # Hashed name for quarantine (e.g., a1b2c3d4...)
safe_source: bool # Whether source needs safe handling (is crontab)
class RestoreOperation(BaseOperation):
"""
Single restore operation in a batch.
The application side handler should:
1. Collect "before" stats of destination file (if exists)
- mtime, size, hash
2. Read file from shared quarantine volume (storage_name)
3. Write it to application storage at destination_path
4. Collect "after" stats of destination file
- mtime, size, hash
5. Return both "before" and "after" stats in response
"""
storage_name: str # Name in quarantine storage (on shared volume)
destination_path: str # Path in application to restore to
safe_destination: bool # Whether destination needs safe handling
class ReadOperation(BaseOperation):
"""
Single read operation in a batch.
The application side handler should:
1. Read file at source_path in application storage
2. Read at most `limit` bytes starting from `offset`
3. Return base64-encoded chunk and file size in response
"""
source_path: str # Path in application (e.g., /var/www/html/file.php)
offset: int # Byte offset to start reading from
limit: int # Maximum number of bytes to read
class CleanPreviewOperation(BaseOperation):
"""
Single clean-preview operation.
The application side handler should:
1. Read infected file from source_path
2. Copy to temp, run procu2.php on the copy
3. Return base64-encoded content of both original and cleaned versions
"""
source_path: str # Path in application (infected file)
class StatOperation(BaseOperation):
"""
Single stat operation in a batch.
The application side handler should:
1. Stat source_path in application storage (following symlinks)
2. Return type flags, size, mtime and POSIX mode in the response
3. Report a non-existent path as a not-found failure, so the client
can map it to FileNotFoundError
"""
source_path: str # Path in application to stat
class ListDirOperation(BaseOperation):
"""
Single directory-listing operation in a batch.
The application side handler should:
1. Read the immediate children of source_path (non-recursive)
2. Return each entry's base name, type flags, size and mtime
3. Cap the number of entries (truncated signals the cap was hit)
"""
source_path: str # Path in application of the directory to list
class BatchRequest(TypedDict):
"""
Batch request containing multiple operations.
Even single operations are wrapped in a one-element list.
"""
request_id: str
command_type: Literal[
"store", "restore", "read", "clean-preview", "stat", "list-dir"
]
operations: (
List[StoreOperation]
| List[RestoreOperation]
| List[ReadOperation]
| List[CleanPreviewOperation]
| List[StatOperation]
| List[ListDirOperation]
)
class FileStats(TypedDict):
"""File statistics for before/after comparison"""
mtime: float # Modification time
size: int # File size in bytes
hash: str # SHA256 hash of file contents
class StoreOperationResult(TypedDict):
"""Result of a single store operation"""
storage_name: str # Which file this result is for
success: bool
error: Optional[str]
class RestoreOperationData(TypedDict):
"""Data for a successful restore operation"""
before: Optional[
FileStats
] # Stats before restore (None if file didn't exist)
after: FileStats # Stats after restore
class RestoreOperationResult(TypedDict):
"""Result of a single restore operation"""
storage_name: str # Which file this result is for
success: bool
error: Optional[str]
data: Optional[RestoreOperationData]
class ReadOperationResult(TypedDict):
"""Result of a single read operation"""
source_path: str # Which file this result is for
success: bool
error: Optional[str]
data: Optional[str] # Base64-encoded file chunk (None on failure)
size: Optional[int] # Total file size in bytes (None on failure)
eof: bool # True if chunk reached end of file
class CleanPreviewResult(TypedDict):
"""Result of a single clean-preview operation"""
source_path: str # Which file this result is for
success: bool
error: Optional[str]
original: Optional[str] # Base64-encoded original (infected) content
cleaned: Optional[
str
] # Base64-encoded cleaned content (empty string if removed)
class StatOperationResult(TypedDict):
"""
Result of a single stat operation.
mtime is Unix seconds with sub-second precision, matching
os.stat_result.st_mtime; mode is the POSIX st_mode. On a not-found
path success is False and error contains "not found" so the client
maps it to FileNotFoundError.
"""
source_path: str # Which file this result is for
success: bool
error: Optional[str]
size: int # File size in bytes
mtime: float # Modification time (Unix seconds, fractional)
mode: int # POSIX st_mode (type bits | permission bits)
is_file: bool
is_dir: bool
class ListDirEntry(TypedDict):
"""One child of a listed directory; name is the base name only."""
name: str
is_file: bool
is_dir: bool
size: int
mtime: float # Modification time (Unix seconds, fractional)
class ListDirOperationResult(TypedDict):
"""
Result of a single list-dir operation.
truncated is True when the directory held more entries than the
syncer's cap — the listing must not be treated as complete.
"""
source_path: str # Which directory this result is for
success: bool
error: Optional[str]
entries: Optional[List[ListDirEntry]] # None on failure
truncated: bool
class BatchResponse(TypedDict):
"""
Batch response containing results for all operations.
Results are returned in the same order as operations in the request.
"""
request_id: str
command_type: Literal[
"store", "restore", "read", "clean-preview", "stat", "list-dir"
]
results: (
List[StoreOperationResult]
| List[RestoreOperationResult]
| List[ReadOperationResult]
| List[CleanPreviewResult]
| List[StatOperationResult]
| List[ListDirOperationResult]
)
# File naming convention
REQUEST_FILE_PATTERN = "{request_id}.request.json"
RESPONSE_FILE_PATTERN = "{request_id}.response.json"
# File locations (shared volume)
REQUESTS_DIR = os.getenv(
"I360_CLEANUP_STORAGE_REQUESTS_DIR", "/var/imunify360/.storage_requests"
)
# Timeouts
#: NFS-backed RWX PVCs (GKE Filestore, EFS) cache negative-entry stat
#: results for up to ~30 s by default (acregmax). The syncer writes the
#: response file in milliseconds, but the agent's stat() can return
#: "no such file" until the cache expires. Setting the timeout above the
#: 30 s cache horizon gives at least one post-expiry poll a chance to
#: see the new file. The waiter still short-circuits as soon as it does.
DEFAULT_REQUEST_TIMEOUT = 90 # seconds
def get_request_filename(request_id: str) -> str:
"""Get the filename for a request"""
return REQUEST_FILE_PATTERN.format(request_id=request_id)
def get_response_filename(request_id: str) -> str:
"""Get the filename for a response"""
return RESPONSE_FILE_PATTERN.format(request_id=request_id)