1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00

feat(storage): support resilient ISO uploads across connection types

This commit is contained in:
catborise 2026-09-28 14:57:31 +03:00
parent 73d8fd71fb
commit 2441b21c0c
5 changed files with 1537 additions and 154 deletions

View file

@ -21,9 +21,13 @@
<div class="row mb-3">
<label for="id_file" class="col-sm-3 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-9">
<input type="file" name="file" id="id_file" class="form-control" required>
<input type="file" name="file" id="id_file" class="form-control" accept=".iso" required>
</div>
</div>
<div id="upload-status" class="d-none align-items-center gap-2 text-primary mt-3" role="status" aria-live="polite">
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span id="upload-status-text">{% trans "Starting upload..." %}</span>
</div>
<div id="upload-progress-container" class="mt-3" style="display: none;">
<div class="progress">
<div id="upload-progress-bar" class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>

View file

@ -223,8 +223,11 @@
}
});
var pendingUpload = null;
var uploadInProgress = false;
$('#isoUploadForm').on('submit', function(e) {
e.preventDefault();
if (uploadInProgress) return;
var fileInput = $('#id_file')[0];
if (fileInput.files.length === 0) {
@ -234,17 +237,42 @@
var file = fileInput.files[0];
var fileName = file.name;
var chunkSize = 10 * 1024 * 1024; // 10MB
var errorMessage = $('#upload-error-message');
if (!/\.iso$/i.test(fileName) || fileName.length <= 4) {
errorMessage.text("{% trans 'Only .iso files can be uploaded.'|escapejs %}").show();
return;
}
if (file.size === 0) {
alert("{% trans 'The selected file is empty.'|escapejs %}");
return;
}
var chunkSize = 32 * 1024 * 1024; // Fewer SSH connections per upload
var totalChunks = Math.ceil(file.size / chunkSize);
var chunkIndex = 0;
var fileSignature = [fileName, file.size, file.lastModified].join(':');
if (!pendingUpload || pendingUpload.signature !== fileSignature) {
pendingUpload = {
signature: fileSignature,
chunkIndex: 0,
uploadId: (window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).substring(2, 10))
};
}
var chunkIndex = pendingUpload.chunkIndex;
var uploadId = pendingUpload.uploadId;
var retryCount = 0;
uploadInProgress = true;
var progressBar = $('#upload-progress-bar');
var progressContainer = $('#upload-progress-container');
var errorMessage = $('#upload-error-message');
var uploadStatus = $('#upload-status');
var uploadStatusText = $('#upload-status-text');
var uploadButton = $('#isoUploadForm button[type="submit"]');
progressContainer.show();
progressBar.width('0%').attr('aria-valuenow', 0).text('0%');
errorMessage.hide();
uploadStatusText.text("{% trans 'Starting upload...'|escapejs %}");
uploadStatus.removeClass('d-none').addClass('d-flex');
uploadButton.prop('disabled', true);
function uploadChunk() {
if (chunkIndex >= totalChunks) {
@ -260,6 +288,8 @@
formData.append('file_name', fileName);
formData.append('chunk_index', chunkIndex);
formData.append('total_chunks', totalChunks);
formData.append('file_size', file.size);
formData.append('upload_id', uploadId);
formData.append('iso_upload', 'true');
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
@ -270,17 +300,39 @@
processData: false,
contentType: false,
success: function(data) {
retryCount = 0;
chunkIndex++;
pendingUpload.chunkIndex = chunkIndex;
var percentComplete = Math.round((chunkIndex / totalChunks) * 100);
progressBar.width(percentComplete + '%').attr('aria-valuenow', percentComplete).text(percentComplete + '%');
uploadStatusText.text("{% trans 'Uploading...'|escapejs %} " + percentComplete + '%');
if (data.reload) {
pendingUpload = null;
uploadInProgress = false;
location.reload();
} else if (chunkIndex < totalChunks) {
uploadChunk();
} else {
pendingUpload = null;
uploadInProgress = false;
uploadStatus.removeClass('d-flex').addClass('d-none');
uploadButton.prop('disabled', false);
}
},
error: function(jqXHR, textStatus, errorThrown) {
if ((jqXHR.status === 0 || jqXHR.status === 429 || jqXHR.status >= 500) && retryCount < 3) {
retryCount++;
uploadStatusText.text("{% trans 'Retrying upload...'|escapejs %}");
setTimeout(uploadChunk, 1000 * Math.pow(2, retryCount - 1));
return;
}
uploadInProgress = false;
uploadStatus.removeClass('d-flex').addClass('d-none');
uploadButton.prop('disabled', false);
if (jqXHR.status >= 400 && jqXHR.status < 500 && jqXHR.status !== 429) {
pendingUpload = null;
}
var errorText = jqXHR.responseJSON && jqXHR.responseJSON.error ? jqXHR.responseJSON.error : "{% trans 'An error occurred during upload.'|escapejs %}";
errorMessage.text(errorText).show();
progressContainer.hide();

View file

@ -1,6 +1,7 @@
import os
import shutil
import tempfile
import time
from unittest.mock import MagicMock, patch
from django.core.files.uploadedfile import SimpleUploadedFile
@ -9,12 +10,102 @@ from django.urls import reverse
from django.contrib.auth import get_user_model
from computes.models import Compute
from storages.views import handle_uploaded_file
from vrtManager.connection import CONN_SOCKET, CONN_SSH
from storages.upload import (
handle_uploaded_file,
_acquire_upload_lock,
_atomic_finalize_ssh,
_cleanup_stale_local_uploads,
_get_upload_lock_directory,
_is_session_completed,
_sftp_replace_file,
)
from vrtManager.connection import CONN_SOCKET, CONN_SSH, CONN_TCP, CONN_TLS
User = get_user_model()
class LibvirtUploadTests(TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.lock_patch = patch("storages.upload._get_upload_lock_directory", return_value=self.temp_dir)
self.lock_patch.start()
self.conn = MagicMock()
self.conn.conn = CONN_TCP
self.volumes = {}
self.conn.get_volumes.side_effect = lambda: list(self.volumes)
self.conn.get_volume.side_effect = lambda name: self.volumes[name]
def create_volume(xml, flags):
from xml.etree import ElementTree
root = ElementTree.fromstring(xml)
name = root.findtext("name")
self.assertNotIn(name, self.volumes)
self.assertEqual(root.find("capacity").get("unit"), "bytes")
self.assertEqual(root.find("target/format").get("type"), "raw")
self.volumes[name] = MagicMock()
self.conn.pool.createXML.side_effect = create_volume
self.conn.wvm.newStream.return_value.send.side_effect = lambda data: len(data)
def tearDown(self):
self.lock_patch.stop()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def upload(self, data, index=0, total=1, upload_id="tcp-session", file_size=None):
return handle_uploaded_file(
self.conn, "/var/lib/libvirt/images", "ubuntu.iso",
SimpleUploadedFile("ubuntu.iso", data), index == total - 1,
chunk_index=index, total_chunks=total, upload_id=upload_id,
user_id=1, compute_id=2, pool="default", file_size=file_size,
)
def test_tcp_upload_retries_and_stream_offsets(self):
self.upload(b"abc", index=0, total=2, file_size=5)
self.upload(b"abc", index=0, total=2, file_size=5)
self.upload(b"de", index=1, total=2, file_size=5)
self.upload(b"de", index=1, total=2, file_size=5)
self.conn.pool.createXML.assert_called_once()
self.assertEqual(self.volumes["ubuntu.iso"].upload.call_count, 2)
self.assertEqual(self.volumes["ubuntu.iso"].upload.call_args.args[1:], (3, 2, 0))
self.assertEqual(self.conn.wvm.newStream.return_value.finish.call_count, 2)
def test_tls_uses_libvirt_stream(self):
self.conn.conn = CONN_TLS
self.upload(b"iso", file_size=3)
self.volumes["ubuntu.iso"].upload.assert_called_once()
def test_existing_volume_is_preserved(self):
self.volumes["ubuntu.iso"] = MagicMock()
with self.assertRaises(FileExistsError):
self.upload(b"iso", file_size=3)
self.conn.pool.createXML.assert_not_called()
def test_size_and_sequence_are_validated(self):
with self.assertRaises(ValueError):
self.upload(b"iso", file_size=None)
with self.assertRaises(ValueError):
self.upload(b"iso", file_size=4)
self.conn.pool.createXML.assert_not_called()
self.upload(b"ab", index=0, total=2, file_size=4)
with self.assertRaises(ValueError):
self.upload(b"cd", index=1, total=2, file_size=5)
with self.assertRaises(ValueError):
self.upload(b"cd", index=1, total=3, file_size=4)
def test_stream_failure_aborts_and_can_retry(self):
stream = self.conn.wvm.newStream.return_value
stream.send.side_effect = [1, OSError("connection lost")]
with self.assertRaises(OSError):
self.upload(b"iso", file_size=3)
stream.abort.assert_called_once()
self.assertEqual(stream.finish.call_count, 0)
stream.send.side_effect = lambda data: len(data)
self.upload(b"iso", file_size=3)
self.conn.pool.createXML.assert_called_once()
class StorageUploadSecurityTests(TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
@ -30,49 +121,507 @@ class StorageUploadSecurityTests(TestCase):
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_handle_uploaded_file_missing_or_invalid_upload_id(self):
dummy_chunk = SimpleUploadedFile("test.iso", b"data")
# Missing upload_id
with self.assertRaises(ValueError) as ctx:
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, "test.iso", dummy_chunk, is_last_chunk=True)
self.assertIn("Missing required upload_id", str(ctx.exception))
# Invalid upload_id format
for bad_id in ["", " ", "sess/../id", "id with spaces", "a" * 65]:
with self.assertRaises(ValueError):
handle_uploaded_file(
self.mock_socket_conn,
self.temp_dir,
"test.iso",
dummy_chunk,
is_last_chunk=True,
upload_id=bad_id,
)
def test_handle_uploaded_file_invalid_names(self):
dummy_chunk = SimpleUploadedFile("test.iso", b"data")
invalid_names = ["", " ", ".", "..", "test\x00evil.iso", "../evil.iso", "../../etc/shadow"]
for bad_name in invalid_names:
clean_base = os.path.basename(bad_name).strip()
# bad_name either gets rejected as empty/dot/null or has basename extracted
if not clean_base or clean_base in (".", "..") or "\x00" in clean_base:
with self.assertRaises(ValueError):
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, bad_name, dummy_chunk, is_last_chunk=True)
handle_uploaded_file(
self.mock_socket_conn,
self.temp_dir,
bad_name,
dummy_chunk,
is_last_chunk=True,
upload_id="sess-valid-1",
)
def test_only_iso_extension_is_accepted(self):
chunk = SimpleUploadedFile("image.img", b"not checked")
for conn in (self.mock_socket_conn, self.mock_ssh_conn):
with self.assertRaisesRegex(ValueError, r"Only \.iso files"):
handle_uploaded_file(
conn, self.temp_dir, "image.img", chunk,
is_last_chunk=True, upload_id="iso-extension-test",
)
handle_uploaded_file(
self.mock_socket_conn, self.temp_dir, "image.ISO",
SimpleUploadedFile("image.ISO", b"contents are not inspected"),
is_last_chunk=True, upload_id="uppercase-iso-test",
)
self.assertTrue(os.path.exists(os.path.join(self.temp_dir, "image.ISO")))
def test_handle_uploaded_file_successful_socket_upload(self):
dummy_chunk = SimpleUploadedFile("install.iso", b"ISODATA_BLOCK_123")
file_name = "install.iso"
upload_id = "sess-install-1"
# Chunk 1 (is_last_chunk=False)
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, dummy_chunk, is_last_chunk=False)
part_path = os.path.join(self.temp_dir, f"{file_name}.part")
# Chunk 1 (is_last_chunk=False, chunk_index=0, total_chunks=2)
handle_uploaded_file(
self.mock_socket_conn,
self.temp_dir,
file_name,
dummy_chunk,
is_last_chunk=False,
chunk_index=0,
total_chunks=2,
upload_id=upload_id,
)
part_path = os.path.join(self.temp_dir, f"{file_name}.wvc_upload_{upload_id}.part")
final_path = os.path.join(self.temp_dir, file_name)
self.assertTrue(os.path.exists(part_path))
self.assertFalse(os.path.exists(final_path))
# Chunk 2 (is_last_chunk=True)
# Chunk 2 (is_last_chunk=True, chunk_index=1, total_chunks=2)
dummy_chunk2 = SimpleUploadedFile("install.iso", b"_PART2")
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, dummy_chunk2, is_last_chunk=True)
handle_uploaded_file(
self.mock_socket_conn,
self.temp_dir,
file_name,
dummy_chunk2,
is_last_chunk=True,
chunk_index=1,
total_chunks=2,
upload_id=upload_id,
)
self.assertFalse(os.path.exists(part_path))
self.assertTrue(os.path.exists(final_path))
with open(final_path, "rb") as f:
self.assertEqual(f.read(), b"ISODATA_BLOCK_123_PART2")
def test_handle_uploaded_file_idempotent_retry(self):
file_name = "retry_test.iso"
upload_id = "sess-retry-123"
c0 = SimpleUploadedFile(file_name, b"CHUNK0_")
c1 = SimpleUploadedFile(file_name, b"CHUNK1_")
c2 = SimpleUploadedFile(file_name, b"CHUNK2")
# Chunk 0 initial
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c0, is_last_chunk=False, chunk_index=0, total_chunks=3, upload_id=upload_id)
# Chunk 0 retry (idempotent no-op!)
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c0, is_last_chunk=False, chunk_index=0, total_chunks=3, upload_id=upload_id)
# Chunk 1 initial
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c1, is_last_chunk=False, chunk_index=1, total_chunks=3, upload_id=upload_id)
# Chunk 1 retry (idempotent no-op!)
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c1, is_last_chunk=False, chunk_index=1, total_chunks=3, upload_id=upload_id)
# Chunk 2 initial (final chunk)
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c2, is_last_chunk=True, chunk_index=2, total_chunks=3, upload_id=upload_id)
# Chunk 2 retry (idempotent no-op, file already finalized!)
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c2, is_last_chunk=True, chunk_index=2, total_chunks=3, upload_id=upload_id)
final_file = os.path.join(self.temp_dir, file_name)
self.assertTrue(os.path.exists(final_file))
with open(final_file, "rb") as f:
self.assertEqual(f.read(), b"CHUNK0_CHUNK1_CHUNK2")
def test_handle_uploaded_file_interrupted_finalization(self):
file_name = "interrupted.iso"
upload_id = "sess-interrupted-1"
c0 = SimpleUploadedFile(file_name, b"PART1")
c1 = SimpleUploadedFile(file_name, b"PART2")
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c0, is_last_chunk=False, chunk_index=0, total_chunks=2, upload_id=upload_id)
# Simulate state where chunk 1 was recorded in .meta and .part, but finalization was interrupted
meta_file = os.path.join(self.temp_dir, f"{file_name}.wvc_upload_{upload_id}.meta")
part_file = os.path.join(self.temp_dir, f"{file_name}.wvc_upload_{upload_id}.part")
with open(part_file, "ab") as f:
f.write(b"PART2")
import json
with open(meta_file, "w") as f:
json.dump({
"file_name": file_name,
"total_chunks": 2,
"expected_chunk": 2,
"current_size": 10,
"created_at": time.time(),
"updated_at": time.time()
}, f)
# Client retries final chunk (chunk 1)
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c1, is_last_chunk=True, chunk_index=1, total_chunks=2, upload_id=upload_id)
final_file = os.path.join(self.temp_dir, file_name)
self.assertTrue(os.path.exists(final_file))
with open(final_file, "rb") as f:
self.assertEqual(f.read(), b"PART1PART2")
def test_handle_uploaded_file_inconsistent_total_chunks(self):
file_name = "inconsistent.iso"
upload_id = "sess-inc-1"
c0 = SimpleUploadedFile(file_name, b"part0")
c1 = SimpleUploadedFile(file_name, b"part1")
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c0, is_last_chunk=False, chunk_index=0, total_chunks=3, upload_id=upload_id)
with self.assertRaises(ValueError) as ctx:
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, c1, is_last_chunk=False, chunk_index=1, total_chunks=4, upload_id=upload_id)
self.assertIn("Inconsistent total_chunks", str(ctx.exception))
def test_handle_uploaded_file_missing_previous_chunk(self):
dummy_chunk = SimpleUploadedFile("broken.iso", b"data")
with self.assertRaises(ValueError):
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, "broken.iso", dummy_chunk, is_last_chunk=False, chunk_index=1, total_chunks=2, upload_id="sess-broken-1")
def test_handle_uploaded_file_destination_exists_raises(self):
file_name = "existing.iso"
final_path = os.path.join(self.temp_dir, file_name)
with open(final_path, "wb") as f:
f.write(b"already exists")
dummy_chunk = SimpleUploadedFile(file_name, b"new data")
with self.assertRaises(Exception):
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, dummy_chunk, is_last_chunk=False, chunk_index=0, total_chunks=2, upload_id="sess-exist-1")
def test_handle_uploaded_file_symlink_defense(self):
file_name = "symlink_test.iso"
upload_id = "sess-symlink-1"
target_file = os.path.join(self.temp_dir, "real.iso")
with open(target_file, "wb") as f:
f.write(b"real content")
symlink_part = os.path.join(self.temp_dir, f"{file_name}.wvc_upload_{upload_id}.part")
os.symlink(target_file, symlink_part)
dummy_chunk = SimpleUploadedFile(file_name, b"malicious chunk")
with self.assertRaises(PermissionError):
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, file_name, dummy_chunk, is_last_chunk=False, chunk_index=0, total_chunks=2, upload_id=upload_id)
def test_handle_uploaded_file_jail_containment(self):
dummy_chunk = SimpleUploadedFile("test.iso", b"data")
# Attempt traversal outside sandbox
with patch("os.path.basename", side_effect=lambda x: x): # simulate raw traversal name
with patch("os.path.basename", side_effect=lambda x: x):
with self.assertRaises(PermissionError):
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, "../../../evil.iso", dummy_chunk, is_last_chunk=True)
handle_uploaded_file(self.mock_socket_conn, self.temp_dir, "../../../evil.iso", dummy_chunk, is_last_chunk=True, upload_id="sess-jail-1")
def test_cleanup_stale_local_uploads_strict_regex(self):
unrelated_files = [
"unrelated.part",
"important.lock",
"data.meta",
"short.123.part",
"backup.abcdefgh.meta",
"disk.12345678.part",
]
for fname in unrelated_files:
p = os.path.join(self.temp_dir, fname)
with open(p, "wb") as f:
f.write(b"keep me")
old_time = time.time() - 172800
os.utime(p, (old_time, old_time))
stale_part = os.path.join(self.temp_dir, "ubuntu.iso.wvc_upload_sess-12345678.part")
stale_meta = os.path.join(self.temp_dir, "ubuntu.iso.wvc_upload_sess-12345678.meta")
fresh_part = os.path.join(self.temp_dir, "ubuntu.iso.wvc_upload_sess-87654321.part")
with open(stale_part, "wb") as f:
f.write(b"stale part")
import json
with open(stale_meta, "w") as f:
json.dump({"upload_id": "sess-12345678", "expected_chunk": 1}, f)
with open(fresh_part, "wb") as f:
f.write(b"fresh part")
old_time = time.time() - 172800
os.utime(stale_part, (old_time, old_time))
os.utime(stale_meta, (old_time, old_time))
_cleanup_stale_local_uploads(self.temp_dir, max_age_seconds=86400)
for fname in unrelated_files:
self.assertTrue(os.path.exists(os.path.join(self.temp_dir, fname)), f"{fname} was unexpectedly deleted")
self.assertTrue(os.path.exists(fresh_part))
self.assertFalse(os.path.exists(stale_part))
self.assertFalse(os.path.exists(stale_meta))
def test_sftp_replace_file_requires_atomic_replace(self):
mock_sftp = MagicMock()
mock_sftp.posix_rename.return_value = None
_sftp_replace_file(mock_sftp, "/tmp/src", "/tmp/dst")
mock_sftp.posix_rename.assert_called_once_with("/tmp/src", "/tmp/dst")
mock_sftp.rename.assert_not_called()
mock_sftp.reset_mock()
mock_sftp.posix_rename.side_effect = IOError("posix_rename unsupported")
with self.assertRaises(OSError):
_sftp_replace_file(mock_sftp, "/tmp/src", "/tmp/dst")
mock_sftp.posix_rename.assert_called_once_with("/tmp/src", "/tmp/dst")
mock_sftp.remove.assert_not_called()
mock_sftp.rename.assert_not_called()
# posix_rename with other error must NOT fallback to remove
mock_sftp.reset_mock()
mock_sftp.posix_rename.side_effect = IOError("permission denied")
with self.assertRaises(IOError):
_sftp_replace_file(mock_sftp, "/tmp/src", "/tmp/dst")
mock_sftp.remove.assert_not_called()
mock_sftp = MagicMock(spec=["remove", "rename"])
with self.assertRaises(OSError):
_sftp_replace_file(mock_sftp, "/tmp/src", "/tmp/dst")
mock_sftp.remove.assert_not_called()
mock_sftp.rename.assert_not_called()
mock_ssh = MagicMock()
stdout = MagicMock()
stdout.channel.recv_exit_status.return_value = 0
mock_ssh.exec_command.return_value = (MagicMock(), stdout, MagicMock())
_sftp_replace_file(mock_sftp, "/tmp/src", "/tmp/dst", ssh=mock_ssh)
mock_sftp.remove.assert_not_called()
@patch("storages.upload.time.sleep")
@patch("storages.upload.fcntl.flock", side_effect=BlockingIOError)
@patch("storages.upload.LOCK_TIMEOUT_SECONDS", 0)
def test_upload_lock_times_out(self, _flock, _sleep):
with self.assertRaises(TimeoutError):
_acquire_upload_lock(123)
def test_atomic_finalize_ssh_success_and_overwrite_prevention(self):
mock_sftp = MagicMock()
_atomic_finalize_ssh(mock_sftp, "/tmp/temp.part", "/tmp/final.iso")
mock_sftp.rename.assert_called_once_with("/tmp/temp.part", "/tmp/final.iso")
# A destination created concurrently remains untouched.
mock_sftp.rename.side_effect = IOError("File exists")
mock_sftp.reset_mock()
with self.assertRaises(FileExistsError):
_atomic_finalize_ssh(mock_sftp, "/tmp/temp.part", "/tmp/final.iso")
mock_sftp.remove.assert_not_called()
def test_is_session_completed_crash_recovery(self):
upload_id = "sess-crash-rec-1"
file_name = "recovered.iso"
final_file = os.path.join(self.temp_dir, file_name)
with open(final_file, "wb") as f:
f.write(b"COMPLETE_CONTENT_12345")
file_size = os.path.getsize(final_file)
meta_file = os.path.join(self.temp_dir, f"{file_name}.wvc_upload_{upload_id}.meta")
import json
with open(meta_file, "w") as mf:
json.dump({
"file_name": file_name,
"expected_chunk": 3,
"total_chunks": 3,
"current_size": file_size,
"created_at": time.time(),
"updated_at": time.time(),
}, mf)
# No .done marker exists yet; _is_session_completed should recover the session from .meta
lock_dir = _get_upload_lock_directory()
marker_path = os.path.join(lock_dir, f"upload_{upload_id}.done")
if os.path.exists(marker_path):
os.unlink(marker_path)
recovered = _is_session_completed(upload_id, file_name, path=self.temp_dir)
self.assertTrue(recovered)
# .meta should now be cleaned up and .done marker created
self.assertFalse(os.path.exists(meta_file))
self.assertTrue(os.path.exists(marker_path))
if os.path.exists(marker_path):
os.unlink(marker_path)
def test_lock_file_not_unlinked_on_completion(self):
upload_id = "sess-lock-persist-1"
file_name = "lock_persist.iso"
dummy_chunk = SimpleUploadedFile(file_name, b"data")
handle_uploaded_file(
self.mock_socket_conn,
self.temp_dir,
file_name,
dummy_chunk,
is_last_chunk=True,
chunk_index=0,
total_chunks=1,
upload_id=upload_id,
)
lock_dir = _get_upload_lock_directory()
lock_path = os.path.join(lock_dir, f"upload_session_{upload_id}.lock")
self.assertTrue(os.path.exists(lock_path))
def test_destination_exists_requires_completed_session(self):
file_name = "already_here.iso"
dest_path = os.path.join(self.temp_dir, file_name)
with open(dest_path, "wb") as f:
f.write(b"existing content")
dummy_chunk = SimpleUploadedFile(file_name, b"new chunk")
with self.assertRaises(FileExistsError):
handle_uploaded_file(
self.mock_socket_conn,
self.temp_dir,
file_name,
dummy_chunk,
is_last_chunk=True,
chunk_index=0,
total_chunks=1,
upload_id="sess-rogue-123",
)
@patch("paramiko.SSHClient")
def test_sftp_upload_multi_chunk_and_retry(self, mock_ssh_cls):
mock_ssh = MagicMock()
mock_ssh_cls.return_value = mock_ssh
mock_sftp = MagicMock()
mock_ssh.open_sftp.return_value = mock_sftp
remote_files = {}
def mock_lstat(path):
if path in remote_files:
stat_obj = MagicMock()
stat_obj.st_mode = 0o100644
return stat_obj
raise FileNotFoundError(f"{path} not found")
mock_sftp.lstat.side_effect = mock_lstat
class FakeRemoteFile:
def __init__(self, path, mode):
self.path = path
self.mode = mode
if "w" in mode or path not in remote_files:
remote_files[path] = b""
self.buf = bytearray(remote_files[path])
self.pos = 0
def write(self, data):
if isinstance(data, str):
data = data.encode("utf-8")
self.buf[self.pos:self.pos + len(data)] = data
self.pos += len(data)
remote_files[self.path] = bytes(self.buf)
def read(self):
return bytes(self.buf)
def seek(self, pos):
self.pos = pos
def truncate(self, size):
del self.buf[size:]
remote_files[self.path] = bytes(self.buf)
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *args):
pass
mock_sftp.open.side_effect = lambda p, m: FakeRemoteFile(p, m)
def mock_rename(src, dst):
if src not in remote_files:
raise FileNotFoundError(f"{src} not found")
remote_files[dst] = remote_files.pop(src)
mock_sftp.rename.side_effect = mock_rename
mock_sftp.posix_rename.side_effect = mock_rename
mock_sftp.remove.side_effect = lambda p: remote_files.pop(p, None)
def mock_exec_command(command, **kwargs):
if "os.replace(" in command:
import shlex
src, dst = shlex.split(command)[-2:]
mock_rename(src, dst)
if "os.link(" in command:
import shlex
src, dst = shlex.split(command)[-2:]
if dst in remote_files:
raise FileExistsError(dst)
remote_files[dst] = remote_files[src]
stdout = MagicMock()
stdout.channel.recv_exit_status.return_value = 0
return MagicMock(), stdout, MagicMock()
mock_ssh.exec_command.side_effect = mock_exec_command
upload_id = "sess-sftp-multi-1"
file_name = "debian.iso"
c0 = SimpleUploadedFile(file_name, b"DEBIAN_CHUNK0_")
c1 = SimpleUploadedFile(file_name, b"DEBIAN_CHUNK1")
handle_uploaded_file(
self.mock_ssh_conn,
"/var/lib/libvirt/images",
file_name,
c0,
is_last_chunk=False,
chunk_index=0,
total_chunks=2,
upload_id=upload_id,
)
self.assertEqual(mock_ssh.connect.call_args.kwargs["timeout"], 10)
mock_sftp.get_channel.return_value.settimeout.assert_called_with(30)
# Simulate bytes written before a worker crashed without advancing metadata.
part_remote = f"/var/lib/libvirt/images/{file_name}.wvc_upload_{upload_id}.part"
remote_files[part_remote] += b"UNCOMMITTED"
handle_uploaded_file(
self.mock_ssh_conn,
"/var/lib/libvirt/images",
file_name,
c1,
is_last_chunk=True,
chunk_index=1,
total_chunks=2,
upload_id=upload_id,
)
final_remote = "/var/lib/libvirt/images/debian.iso"
self.assertIn(final_remote, remote_files)
self.assertEqual(remote_files[final_remote], b"DEBIAN_CHUNK0_DEBIAN_CHUNK1")
# Retry Chunk 1
handle_uploaded_file(
self.mock_ssh_conn,
"/var/lib/libvirt/images",
file_name,
c1,
is_last_chunk=True,
chunk_index=1,
total_chunks=2,
upload_id=upload_id,
)
self.assertEqual(remote_files[final_remote], b"DEBIAN_CHUNK0_DEBIAN_CHUNK1")
def test_handle_uploaded_file_ssh_traversal_check(self):
dummy_chunk = SimpleUploadedFile("test.iso", b"data")
with patch("os.path.basename", side_effect=lambda x: x):
with self.assertRaises(PermissionError):
handle_uploaded_file(self.mock_ssh_conn, "/var/lib/libvirt/images", "../../../evil.iso", dummy_chunk, is_last_chunk=True)
handle_uploaded_file(self.mock_ssh_conn, "/var/lib/libvirt/images", "../../../evil.iso", dummy_chunk, is_last_chunk=True, upload_id="sess-ssh-1")
class StorageUploadViewTests(TestCase):
@ -81,6 +630,44 @@ class StorageUploadViewTests(TestCase):
self.client.login(username="storage_admin", password="password")
self.compute = Compute.objects.create(name="LocalCompute", hostname="localhost", type=1, login="u", password="p")
@patch("storages.views.wvmStorage")
def test_iso_upload_rejects_other_extensions(self, mock_wvm):
mock_conn = mock_wvm.return_value
mock_conn.conn = CONN_SOCKET
mock_conn.get_target_path.return_value = "/tmp"
url = reverse("storage", args=[self.compute.id, "default"])
response = self.client.post(url, {
"iso_upload": "1", "file": SimpleUploadedFile("notes.txt", b"notes"),
"file_name": "notes.txt", "chunk_index": "0", "total_chunks": "1",
"upload_id": "invalid-extension-test",
})
self.assertEqual(response.status_code, 400)
self.assertIn("Only .iso files", response.json()["error"])
@patch("storages.views.handle_uploaded_file")
@patch("storages.views.wvmStorage")
def test_tcp_upload_passes_declared_size(self, mock_wvm, mock_upload):
mock_conn = mock_wvm.return_value
mock_conn.conn = CONN_TCP
mock_conn.get_target_path.return_value = "/var/lib/libvirt/images"
url = reverse("storage", args=[self.compute.id, "default"])
payload = {
"iso_upload": "1", "file_name": "test.iso", "chunk_index": "0",
"total_chunks": "1", "upload_id": "tcp-view-test",
}
response = self.client.post(url, {
**payload, "file": SimpleUploadedFile("test.iso", b"iso"),
})
self.assertEqual(response.status_code, 400)
mock_upload.assert_not_called()
response = self.client.post(url, {
**payload, "file": SimpleUploadedFile("test.iso", b"iso"),
"file_size": "3",
})
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_upload.call_args.kwargs["file_size"], 3)
@patch("storages.views.wvmStorage")
def test_iso_upload_missing_chunk(self, mock_wvm):
mock_conn = MagicMock()
@ -93,7 +680,7 @@ class StorageUploadViewTests(TestCase):
mock_wvm.return_value = mock_conn
url = reverse("storage", args=[self.compute.id, "default"])
res = self.client.post(url, {"iso_upload": "1", "file_name": "test.iso"})
res = self.client.post(url, {"iso_upload": "1", "file_name": "test.iso", "upload_id": "sess-view-1"})
self.assertEqual(res.status_code, 400)
self.assertIn("No file chunk was submitted", res.json().get("error", ""))
@ -116,6 +703,109 @@ class StorageUploadViewTests(TestCase):
"file_name": "..\x00..",
"chunk_index": "0",
"total_chunks": "1",
"upload_id": "sess-view-2",
})
self.assertEqual(res.status_code, 400)
self.assertIn("Invalid file name", res.json().get("error", ""))
@patch("storages.views.wvmStorage")
def test_iso_upload_missing_upload_id(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.get_status.return_value = 1
mock_conn.get_target_path.return_value = "/tmp"
mock_conn.get_type.return_value = "dir"
mock_conn.get_autostart.return_value = 1
mock_conn.get_size.return_value = [1000, 500]
mock_conn.update_volumes.return_value = []
mock_wvm.return_value = mock_conn
url = reverse("storage", args=[self.compute.id, "default"])
chunk = SimpleUploadedFile("test.iso", b"chunkdata")
res = self.client.post(url, {
"iso_upload": "1",
"file": chunk,
"file_name": "test.iso",
"chunk_index": "0",
"total_chunks": "1",
})
self.assertEqual(res.status_code, 400)
self.assertIn("Missing upload_id", res.json().get("error", ""))
@patch("storages.views.wvmStorage")
def test_iso_upload_final_chunk_retry_idempotency(self, mock_wvm):
mock_conn = MagicMock()
mock_conn.conn = CONN_SOCKET
mock_conn.get_status.return_value = 1
temp_dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, temp_dir, ignore_errors=True)
mock_conn.get_target_path.return_value = temp_dir
mock_conn.get_type.return_value = "dir"
mock_conn.get_autostart.return_value = 1
mock_conn.get_size.return_value = [1000, 500]
mock_conn.get_volumes.return_value = []
mock_conn.update_volumes.return_value = []
mock_wvm.return_value = mock_conn
url = reverse("storage", args=[self.compute.id, "default"])
upload_id = "sess-view-retry-123"
file_name = "test_retry.iso"
chunk = SimpleUploadedFile(file_name, b"test payload")
# 1. First upload (single chunk, total_chunks=1)
res1 = self.client.post(url, {
"iso_upload": "1",
"file": chunk,
"file_name": file_name,
"chunk_index": "0",
"total_chunks": "1",
"upload_id": upload_id,
})
self.assertEqual(res1.status_code, 200)
self.assertTrue(res1.json().get("success"))
# Volume now appears in libvirt pool
mock_conn.get_volumes.return_value = [file_name]
# 2. Retrying final chunk with same upload_id returns 200 idempotent success
chunk_retry = SimpleUploadedFile(file_name, b"test payload")
res2 = self.client.post(url, {
"iso_upload": "1",
"file": chunk_retry,
"file_name": file_name,
"chunk_index": "0",
"total_chunks": "1",
"upload_id": upload_id,
})
self.assertEqual(res2.status_code, 200)
self.assertTrue(res2.json().get("success"))
mock_conn.get_storages.assert_not_called()
mock_conn.update_volumes.assert_not_called()
mock_conn.refresh.assert_not_called()
self.assertTrue(res2.json().get("reload"))
# 3. Upload with different upload_id returns 400 error
chunk_attacker = SimpleUploadedFile(file_name, b"evil payload")
res3 = self.client.post(url, {
"iso_upload": "1",
"file": chunk_attacker,
"file_name": file_name,
"chunk_index": "0",
"total_chunks": "1",
"upload_id": "sess-attacker-999",
})
self.assertEqual(res3.status_code, 400)
self.assertIn("ISO image already exists", res3.json().get("error", ""))
# 4. Upload with same upload_id but by a different superuser cannot bypass/claim
User.objects.create_superuser(username="other_admin", password="password", email="other@example.com")
self.client.login(username="other_admin", password="password")
res4 = self.client.post(url, {
"iso_upload": "1",
"file": chunk_retry,
"file_name": file_name,
"chunk_index": "0",
"total_chunks": "1",
"upload_id": upload_id,
})
self.assertEqual(res4.status_code, 400)
self.assertIn("ISO image already exists", res4.json().get("error", ""))

692
storages/upload.py Normal file
View file

@ -0,0 +1,692 @@
"""Chunked ISO upload operations shared by the storage view."""
import fcntl
import json
import logging
import os
import posixpath
import re
import stat
import tempfile
import time
from xml.sax.saxutils import escape
import paramiko
from django.utils.translation import gettext_lazy as _
from vrtManager.connection import CONN_SSH, CONN_SOCKET, CONN_TCP, CONN_TLS
logger = logging.getLogger(__name__)
LOCK_TIMEOUT_SECONDS = 15
SSH_CONNECT_TIMEOUT_SECONDS = 10
SSH_IO_TIMEOUT_SECONDS = 30
def _fsync_dir(dir_path):
if not dir_path or not os.path.isdir(dir_path):
return
try:
dir_fd = os.open(dir_path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
def _atomic_finalize_local(target_temp, target_final):
parent_dir = os.path.dirname(target_final)
# 1. Primary: POSIX os.link creates a hard link atomically and fails with FileExistsError if target_final exists.
try:
os.link(target_temp, target_final)
_fsync_dir(parent_dir)
try:
os.unlink(target_temp)
_fsync_dir(parent_dir)
except OSError:
pass
return
except FileExistsError:
raise FileExistsError(_("Destination file already exists"))
except OSError as link_err:
# 2. Secondary: Linux kernel renameat2 with RENAME_NOREPLACE (flag 1)
try:
import ctypes
libc = ctypes.CDLL("libc.so.6", use_errno=True)
ret = libc.renameat2(
ctypes.c_int(-100),
target_temp.encode("utf-8"),
ctypes.c_int(-100),
target_final.encode("utf-8"),
ctypes.c_uint(1),
)
if ret == 0:
_fsync_dir(parent_dir)
return
err = ctypes.get_errno()
if err == 17: # EEXIST
raise FileExistsError(_("Destination file already exists"))
except (AttributeError, OSError):
pass
# If atomic no-replace cannot be guaranteed, fail safely without destroying target_temp
raise OSError(_("Filesystem does not support atomic no-replace finalization: {}").format(link_err))
def _atomic_finalize_ssh(sftp, target_temp, target_final):
"""
Finalize with standard SFTP rename, which requires an unused destination.
Atomic visibility depends on the remote SFTP server and filesystem.
"""
try:
sftp.rename(target_temp, target_final)
except OSError as e:
if "exist" in str(e).lower() or "already" in str(e).lower():
raise FileExistsError(_("Destination file already exists")) from e
raise
_STALE_UPLOAD_PATTERN = re.compile(r"^.+\.wvc_upload_[a-zA-Z0-9_\-]{8,64}\.(part|meta|meta\.tmp)$")
def _sftp_replace_file(sftp, src, dst, ssh=None):
"""Replace metadata atomically, leaving the old copy intact on failure."""
posix_rename = getattr(sftp, "posix_rename", None)
if callable(posix_rename):
try:
posix_rename(src, dst)
return
except (IOError, OSError) as e:
err_str = str(e).lower()
if not ("unsupported" in err_str or getattr(e, "code", None) == 8 or isinstance(e, NotImplementedError)):
raise
if ssh is not None:
import shlex
cmd = f"python3 -c 'import os, sys; os.replace(sys.argv[1], sys.argv[2])' {shlex.quote(src)} {shlex.quote(dst)}"
try:
stdin, stdout, stderr = ssh.exec_command(cmd, timeout=SSH_IO_TIMEOUT_SECONDS)
if stdout.channel.recv_exit_status() == 0:
return
except Exception as exc:
raise OSError(_("Remote server cannot safely replace upload metadata")) from exc
raise OSError(_("Remote server cannot safely replace upload metadata"))
def _cleanup_stale_local_uploads(base_dir, max_age_seconds=86400):
try:
now = time.time()
for entry in os.scandir(base_dir):
if entry.is_file() and _STALE_UPLOAD_PATTERN.match(entry.name):
try:
mtime = entry.stat().st_mtime
if now - mtime > max_age_seconds:
if entry.name.endswith(".meta"):
try:
with open(entry.path, "r", encoding="utf-8") as f:
meta = json.load(f)
if not meta.get("upload_id") and not meta.get("expected_chunk"):
continue
except Exception:
pass
os.unlink(entry.path)
except OSError:
pass
except OSError:
pass
def _cleanup_stale_sftp_uploads(sftp, remote_base, max_age_seconds=86400):
try:
now = time.time()
for attr in sftp.listdir_attr(remote_base):
name = attr.filename
if _STALE_UPLOAD_PATTERN.match(name):
try:
if attr.st_mtime and (now - attr.st_mtime > max_age_seconds):
target_file = posixpath.join(remote_base, name)
sftp.remove(target_file)
except Exception:
pass
except Exception:
pass
def _get_upload_lock_directory():
candidates = [
"/srv/webvirtcloud/data/locks",
os.path.join(tempfile.gettempdir(), f"webvirtcloud_upload_locks_{os.geteuid()}"),
]
for candidate in candidates:
try:
if os.path.islink(candidate):
continue
if not os.path.exists(candidate):
os.makedirs(candidate, mode=0o700, exist_ok=True)
if not os.path.isdir(candidate) or os.path.islink(candidate):
continue
stat_info = os.stat(candidate)
if stat_info.st_uid != os.geteuid():
continue
if (stat.S_IMODE(stat_info.st_mode) & 0o077) != 0:
try:
os.chmod(candidate, 0o700)
except OSError:
continue
stat_info = os.stat(candidate)
if (stat.S_IMODE(stat_info.st_mode) & 0o077) != 0:
continue
return candidate
except (OSError, PermissionError):
continue
return None
def _cleanup_stale_lock_dir(lock_dir, max_age_seconds=86400):
if not lock_dir or not os.path.exists(lock_dir):
return
try:
now = time.time()
for entry in os.scandir(lock_dir):
if entry.is_file():
if entry.name.startswith("upload_") and (entry.name.endswith(".done") or entry.name.endswith(".done.tmp")):
try:
if now - entry.stat().st_mtime > max_age_seconds:
os.unlink(entry.path)
except OSError:
pass
except OSError:
pass
def _mark_session_completed(lock_dir, upload_id, file_name, path=None, user_id=None, compute_id=None, pool=None):
if not lock_dir or not upload_id or not file_name:
return
marker_path = os.path.join(lock_dir, f"upload_{upload_id}.done")
if os.path.islink(marker_path):
try:
os.unlink(marker_path)
except OSError:
pass
try:
tmp_marker = f"{marker_path}.tmp"
marker_data = {
"upload_id": upload_id,
"file_name": file_name,
"path": posixpath.normpath(path) if path else None,
"user_id": user_id,
"compute_id": compute_id,
"pool": pool,
"completed_at": time.time(),
}
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(tmp_marker, flags, 0o600)
with open(fd, "w", encoding="utf-8", closefd=True) as f:
json.dump(marker_data, f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_marker, marker_path)
_fsync_dir(lock_dir)
except Exception as e:
logger.error("Failed to write upload session completion marker %s: %s", marker_path, e)
raise
def _is_session_completed(upload_id, file_name, path=None, user_id=None, compute_id=None, pool=None, lock_dir=None, check_local_recovery=True):
if not upload_id or not file_name:
return False
if not lock_dir:
lock_dir = _get_upload_lock_directory()
if not lock_dir:
return False
marker_path = os.path.join(lock_dir, f"upload_{upload_id}.done")
if os.path.exists(marker_path) and not os.path.islink(marker_path):
try:
with open(marker_path, "r", encoding="utf-8") as f:
data = json.load(f)
marker_valid = True
if data.get("upload_id") != upload_id or data.get("file_name") != file_name:
marker_valid = False
if path and data.get("path"):
if posixpath.normpath(path) != posixpath.normpath(data["path"]):
marker_valid = False
if user_id is not None and data.get("user_id") is not None:
if str(data["user_id"]) != str(user_id):
marker_valid = False
if compute_id is not None and data.get("compute_id") is not None:
if str(data["compute_id"]) != str(compute_id):
marker_valid = False
if pool is not None and data.get("pool") is not None:
if str(data["pool"]) != str(pool):
marker_valid = False
if time.time() - data.get("completed_at", 0) > 86400:
try:
os.unlink(marker_path)
_fsync_dir(lock_dir)
except OSError:
pass
marker_valid = False
if marker_valid:
return True
except Exception:
pass
# Crash-recovery: check if metadata file exists in path with complete chunks and matching size
if path and check_local_recovery:
meta_suffix = f".wvc_upload_{upload_id}.meta"
meta_path = os.path.join(path, f"{file_name}{meta_suffix}")
final_path = os.path.join(path, file_name)
if os.path.exists(meta_path) and os.path.exists(final_path) and not os.path.islink(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as mf:
meta = json.load(mf)
if (
meta.get("file_name") == file_name
and meta.get("expected_chunk") == meta.get("total_chunks")
and os.path.getsize(final_path) == meta.get("current_size")
):
try:
_mark_session_completed(
lock_dir, upload_id, file_name, path,
user_id=user_id, compute_id=compute_id, pool=pool,
)
os.unlink(meta_path)
_fsync_dir(path)
except Exception:
pass
return True
except Exception:
pass
return False
class _UploadTarget:
"""Filesystem differences for one upload; chunk ordering lives in one place."""
def __init__(self, conn, path, file_name, upload_id):
self.conn = conn
self.remote = conn.conn == CONN_SSH
if not self.remote and conn.conn != CONN_SOCKET:
raise ValueError(_("Unsupported connection type for file upload."))
self.base = posixpath.normpath(path) if self.remote else os.path.abspath(path)
join = posixpath.join if self.remote else os.path.join
normalize = posixpath.normpath if self.remote else os.path.abspath
self.final = normalize(join(self.base, file_name))
self.part = normalize(join(self.base, f"{file_name}.wvc_upload_{upload_id}.part"))
self.meta = normalize(join(self.base, f"{file_name}.wvc_upload_{upload_id}.meta"))
for target in (self.final, self.part, self.meta):
if self.remote:
contained = target.startswith(self.base.rstrip("/") + "/")
else:
contained = os.path.commonpath((self.base, target)) == self.base
if not contained:
raise PermissionError(_("Security issues with file uploading: path traversal detected"))
self.ssh = None
self.sftp = None
def __enter__(self):
if self.remote:
hostname, separator, port = self.conn.host.partition(":")
self.ssh = paramiko.SSHClient()
try:
self.ssh.load_system_host_keys()
self.ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
self.ssh.connect(
hostname=hostname, port=int(port) if separator else 22,
username=self.conn.login, password=self.conn.passwd,
timeout=SSH_CONNECT_TIMEOUT_SECONDS,
banner_timeout=SSH_CONNECT_TIMEOUT_SECONDS,
auth_timeout=SSH_CONNECT_TIMEOUT_SECONDS,
channel_timeout=SSH_CONNECT_TIMEOUT_SECONDS,
)
self.sftp = self.ssh.open_sftp()
self.sftp.get_channel().settimeout(SSH_IO_TIMEOUT_SECONDS)
except Exception:
self.ssh.close()
raise
return self
def __exit__(self, *_):
try:
if self.sftp is not None:
self.sftp.close()
finally:
if self.ssh is not None:
self.ssh.close()
def info(self, path):
try:
info = self.sftp.lstat(path) if self.remote else os.lstat(path)
except FileNotFoundError:
return None
if stat.S_ISLNK(info.st_mode):
raise PermissionError(_("Security issues with file uploading: symlink detected"))
return info
def read_meta(self):
if self.info(self.meta) is None:
return None
try:
if self.remote:
with self.sftp.open(self.meta, "r") as stream:
raw = stream.read()
else:
with open(self.meta, "r", encoding="utf-8") as stream:
raw = stream.read()
return json.loads(raw)
except Exception as exc:
raise ValueError(_("Corrupted upload session metadata")) from exc
def write_meta(self, data):
temporary = self.meta + ".tmp"
if self.remote:
if self.info(temporary) is not None:
self.sftp.remove(temporary)
with self.sftp.open(temporary, "w") as stream:
stream.write(json.dumps(data))
if callable(getattr(stream, "flush", None)):
stream.flush()
_sftp_replace_file(self.sftp, temporary, self.meta, ssh=self.ssh)
else:
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(temporary, flags, 0o600)
with open(fd, "w", encoding="utf-8", closefd=True) as stream:
json.dump(data, stream)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, self.meta)
_fsync_dir(self.base)
def write_chunk(self, chunk, offset, new):
if self.remote:
with self.sftp.open(self.part, "wb" if new else "r+b") as stream:
if not new:
stream.seek(offset)
stream.truncate(offset)
for data in chunk.chunks():
stream.write(data)
offset += len(data)
if callable(getattr(stream, "flush", None)):
stream.flush()
return offset
flags = (os.O_WRONLY | os.O_CREAT | os.O_EXCL) if new else os.O_RDWR
flags |= getattr(os, "O_NOFOLLOW", 0)
fd = os.open(self.part, flags, 0o600)
with open(fd, "wb" if new else "r+b", closefd=True) as stream:
if not new:
stream.seek(offset)
stream.truncate()
for data in chunk.chunks():
stream.write(data)
offset += len(data)
stream.flush()
os.fsync(stream.fileno())
return offset
def finalize(self):
if self.remote:
_atomic_finalize_ssh(self.sftp, self.part, self.final)
else:
_atomic_finalize_local(self.part, self.final)
def remove_meta(self):
try:
if self.remote:
self.sftp.remove(self.meta)
else:
os.unlink(self.meta)
_fsync_dir(self.base)
except (OSError, IOError):
pass
def cleanup(self, lock_dir):
if self.remote:
_cleanup_stale_sftp_uploads(self.sftp, self.base)
else:
_cleanup_stale_local_uploads(self.base)
_cleanup_stale_lock_dir(lock_dir)
def _matches_session(meta, file_name, total_chunks, user_id, compute_id, pool):
if meta.get("file_name") != file_name:
raise ValueError(_("File name does not match upload session"))
if meta.get("total_chunks") != total_chunks:
raise ValueError(_("Inconsistent total_chunks for session"))
for key, expected in (("user_id", user_id), ("compute_id", compute_id), ("pool", pool)):
if expected is not None and meta.get(key) is not None and str(meta[key]) != str(expected):
raise PermissionError(_("Upload session belongs to another request context"))
def _acquire_upload_lock(lock_fd):
deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
while True:
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return
except BlockingIOError:
if time.monotonic() >= deadline:
raise TimeoutError(_("Timed out waiting for upload session lock"))
time.sleep(0.05)
def _write_libvirt_meta(meta_path, data):
temporary = meta_path + ".tmp"
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(temporary, flags, 0o600)
with open(fd, "w", encoding="utf-8", closefd=True) as stream:
json.dump(data, stream)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, meta_path)
_fsync_dir(os.path.dirname(meta_path))
def _handle_libvirt_upload(
conn, path, file_name, file_chunk, is_last_chunk, chunk_index,
total_chunks, upload_id, user_id, compute_id, pool, file_size, lock_dir,
):
"""Upload to a remote pool through the existing libvirt connection."""
if not isinstance(file_size, int) or file_size <= 0:
raise ValueError(_("A positive file_size is required for TCP/TLS uploads"))
if file_chunk.size <= 0:
raise ValueError(_("Empty upload chunk"))
meta_path = os.path.join(lock_dir, f"upload_{upload_id}.libvirt.json")
if os.path.islink(meta_path):
raise PermissionError(_("Invalid upload session metadata"))
if os.path.exists(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as stream:
meta = json.load(stream)
except (OSError, ValueError) as exc:
raise ValueError(_("Corrupted upload session metadata")) from exc
_matches_session(meta, file_name, total_chunks, user_id, compute_id, pool)
if meta.get("path") != path or meta.get("file_size") != file_size:
raise ValueError(_("Upload session parameters changed"))
else:
if chunk_index != 0:
raise ValueError(_("Upload session not found or expired"))
meta = {
"file_name": file_name, "total_chunks": total_chunks,
"expected_chunk": 0, "current_size": 0, "file_size": file_size,
"path": path, "user_id": user_id, "compute_id": compute_id,
"pool": pool, "created_at": time.time(),
}
expected = meta["expected_chunk"]
if chunk_index < expected:
if is_last_chunk and expected == total_chunks:
_mark_session_completed(
lock_dir, upload_id, file_name, path,
user_id=user_id, compute_id=compute_id, pool=pool,
)
os.unlink(meta_path)
_fsync_dir(lock_dir)
return
if chunk_index != expected:
raise ValueError(
_("Invalid chunk sequence. Expected chunk %(exp)d, got %(got)d")
% {"exp": expected, "got": chunk_index}
)
offset = meta["current_size"]
if offset + file_chunk.size > file_size:
raise ValueError(_("Upload exceeds declared file size"))
if is_last_chunk and offset + file_chunk.size != file_size:
raise ValueError(_("Final chunk does not match declared file size"))
# A volume is visible as soon as libvirt creates it. Keep the session until
# the final chunk finishes so retries can resume safely at a known offset.
volumes = conn.get_volumes()
if meta["expected_chunk"] == 0 and not os.path.exists(meta_path):
if file_name in volumes:
raise FileExistsError(_("Destination file already exists"))
xml = (
"<volume><name>{}</name><capacity unit='bytes'>{}</capacity>"
"<target><format type='raw'/></target></volume>"
).format(escape(file_name), file_size)
conn.pool.createXML(xml, 0)
_write_libvirt_meta(meta_path, meta)
elif file_name not in volumes:
raise ValueError(_("Upload volume is missing"))
volume = conn.get_volume(file_name)
stream = conn.wvm.newStream(0)
try:
volume.upload(stream, offset, file_chunk.size, 0)
written = 0
for data in file_chunk.chunks():
while data:
sent = stream.send(data)
if sent <= 0:
raise OSError(_("Libvirt stream stopped during upload"))
written += sent
data = data[sent:]
if written != file_chunk.size:
raise OSError(_("Upload chunk size changed during transfer"))
stream.finish()
except Exception:
stream.abort()
raise
meta["current_size"] = offset + written
meta["expected_chunk"] = expected + 1
meta["updated_at"] = time.time()
_write_libvirt_meta(meta_path, meta)
if is_last_chunk:
_mark_session_completed(
lock_dir, upload_id, file_name, path,
user_id=user_id, compute_id=compute_id, pool=pool,
)
os.unlink(meta_path)
_fsync_dir(lock_dir)
def handle_uploaded_file(
conn, path, file_name, file_chunk, is_last_chunk, chunk_index=0,
total_chunks=1, upload_id=None, user_id=None, compute_id=None, pool=None,
file_size=None,
):
clean_name = os.path.basename(file_name).strip()
if not clean_name or clean_name in (".", "..") or "\x00" in clean_name:
raise ValueError(_("Invalid file name"))
if len(clean_name) <= 4 or not clean_name.lower().endswith(".iso"):
raise ValueError(_("Only .iso files can be uploaded"))
if total_chunks < 1 or chunk_index < 0 or chunk_index >= total_chunks:
raise ValueError(_("Invalid chunk index or total chunks."))
if is_last_chunk != (chunk_index == total_chunks - 1):
raise ValueError(_("Invalid final chunk flag"))
if not upload_id or not isinstance(upload_id, str):
raise ValueError(_("Missing required upload_id"))
upload_id = upload_id.strip()
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", upload_id):
raise ValueError(_("Invalid upload_id format"))
libvirt_upload = conn.conn in (CONN_TCP, CONN_TLS)
target = None if libvirt_upload else _UploadTarget(conn, path, clean_name, upload_id)
lock_dir = _get_upload_lock_directory()
if not lock_dir:
raise OSError(_("Could not acquire secure upload lock directory"))
lock_path = os.path.join(lock_dir, f"upload_session_{upload_id}.lock")
flags = os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0)
lock_fd = os.open(lock_path, flags, 0o600)
try:
_acquire_upload_lock(lock_fd)
if libvirt_upload:
if _is_session_completed(
upload_id, clean_name, path, user_id=user_id,
compute_id=compute_id, pool=pool, lock_dir=lock_dir,
check_local_recovery=False,
):
if is_last_chunk:
if clean_name not in conn.get_volumes():
raise ValueError(_("Upload volume is missing"))
return
raise FileExistsError(_("Destination file already exists"))
return _handle_libvirt_upload(
conn, path, clean_name, file_chunk, is_last_chunk, chunk_index,
total_chunks, upload_id, user_id, compute_id, pool, file_size, lock_dir,
)
with target:
if chunk_index == 0:
target.cleanup(lock_dir)
final_info = target.info(target.final)
if final_info is not None:
if is_last_chunk and _is_session_completed(
upload_id, clean_name, target.base, user_id=user_id,
compute_id=compute_id, pool=pool, lock_dir=lock_dir,
):
return
# A crash after rename but before the completion marker is recoverable.
meta = target.read_meta() if is_last_chunk else None
if meta is not None:
_matches_session(meta, clean_name, total_chunks, user_id, compute_id, pool)
if (meta.get("expected_chunk") == total_chunks
and final_info.st_size == meta.get("current_size")):
_mark_session_completed(lock_dir, upload_id, clean_name, target.base,
user_id=user_id, compute_id=compute_id, pool=pool)
target.remove_meta()
return
raise FileExistsError(_("Destination file already exists"))
meta = target.read_meta()
if meta is None:
if chunk_index != 0 or target.info(target.part) is not None:
raise ValueError(_("Upload session not found or expired"))
meta = {
"file_name": clean_name, "total_chunks": total_chunks,
"expected_chunk": 0, "current_size": 0,
"created_at": time.time(), "user_id": user_id,
"compute_id": compute_id, "pool": pool,
}
else:
_matches_session(meta, clean_name, total_chunks, user_id, compute_id, pool)
if target.info(target.part) is None:
raise ValueError(_("Upload session not found or expired"))
expected = meta["expected_chunk"]
if chunk_index < expected:
if is_last_chunk and expected == total_chunks:
target.finalize()
_mark_session_completed(lock_dir, upload_id, clean_name, target.base,
user_id=user_id, compute_id=compute_id, pool=pool)
target.remove_meta()
return
if chunk_index != expected:
raise ValueError(
_("Invalid chunk sequence. Expected chunk %(exp)d, got %(got)d")
% {"exp": expected, "got": chunk_index}
)
meta["current_size"] = target.write_chunk(file_chunk, meta["current_size"], new=expected == 0)
meta["expected_chunk"] = expected + 1
meta["updated_at"] = time.time()
target.write_meta(meta)
if is_last_chunk:
target.finalize()
_mark_session_completed(lock_dir, upload_id, clean_name, target.base,
user_id=user_id, compute_id=compute_id, pool=pool)
target.remove_meta()
finally:
os.close(lock_fd)

View file

@ -1,6 +1,6 @@
import json
import os
import posixpath
import re
from admin.decorators import superuser_only
from appsettings.settings import app_settings
@ -11,12 +11,84 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from libvirt import libvirtError
import paramiko
from vrtManager.connection import CONN_SSH, CONN_SOCKET
from vrtManager.connection import CONN_TCP, CONN_TLS
from vrtManager.storage import wvmStorage, wvmStorages
from storages.forms import AddStgPool, CloneImage, CreateVolumeForm
from storages.upload import handle_uploaded_file
def _handle_iso_upload(request, conn, path, compute, pool):
file_chunk = request.FILES.get("file")
if not file_chunk:
return JsonResponse({"error": _("No file chunk was submitted.")}, status=400)
file_name = request.POST.get("file_name", "")
clean_file_name = os.path.basename(file_name).strip()
if not clean_file_name or clean_file_name in (".", "..") or "\x00" in clean_file_name:
return JsonResponse({"error": _("Invalid file name.")}, status=400)
file_name = clean_file_name
try:
chunk_index = int(request.POST.get("chunk_index", 0))
total_chunks = int(request.POST.get("total_chunks", 1))
except (ValueError, TypeError):
return JsonResponse({"error": _("Invalid chunk parameters.")}, status=400)
if total_chunks < 1 or chunk_index < 0 or chunk_index >= total_chunks:
return JsonResponse({"error": _("Invalid chunk index or total chunks.")}, status=400)
is_last_chunk = chunk_index == total_chunks - 1
file_size = None
if conn.conn in (CONN_TCP, CONN_TLS):
try:
file_size = int(request.POST.get("file_size", ""))
except (ValueError, TypeError):
return JsonResponse({"error": _("Invalid file size.")}, status=400)
if file_size <= 0:
return JsonResponse({"error": _("Invalid file size.")}, status=400)
upload_id = request.POST.get("upload_id")
if not upload_id or not isinstance(upload_id, str):
return JsonResponse({"error": _("Missing upload_id.")}, status=400)
upload_id = upload_id.strip()
if not re.match(r"^[a-zA-Z0-9_\-]+$", upload_id) or len(upload_id) > 64:
return JsonResponse({"error": _("Invalid upload_id format.")}, status=400)
try:
handle_uploaded_file(
conn,
path,
file_name,
file_chunk,
is_last_chunk,
chunk_index=chunk_index,
total_chunks=total_chunks,
upload_id=upload_id,
user_id=request.user.id,
compute_id=compute.id,
pool=pool,
file_size=file_size,
)
if is_last_chunk:
success_msg = _("ISO: %(file)s has been uploaded successfully.") % {"file": file_name}
messages.success(request, success_msg)
return JsonResponse({"success": True, "message": success_msg, "reload": True})
else:
return JsonResponse({"success": True, "message": "Chunk received."})
except FileExistsError:
return JsonResponse({"error": _("ISO image already exists")}, status=400)
except TimeoutError:
return JsonResponse({"error": _("Upload session is busy. Please retry.")}, status=429)
except ValueError as e:
return JsonResponse({"error": str(e)}, status=400)
except Exception as e:
error_msg = str(e)
messages.error(request, error_msg)
return JsonResponse({"error": error_msg}, status=500)
@superuser_only
@ -98,74 +170,6 @@ def storages(request, compute_id):
return render(request, "storages.html", locals())
def handle_uploaded_file(conn, path, file_name, file_chunk, is_last_chunk):
clean_name = os.path.basename(file_name).strip()
if not clean_name or clean_name in (".", "..") or "\x00" in clean_name:
raise ValueError(_("Invalid file name"))
if conn.conn == CONN_SSH:
remote_base = posixpath.normpath(path)
target_temp = posixpath.normpath(posixpath.join(remote_base, f"{clean_name}.part"))
target_final = posixpath.normpath(posixpath.join(remote_base, clean_name))
if not (target_temp.startswith(remote_base + "/") or target_temp == remote_base) or \
not (target_final.startswith(remote_base + "/") or target_final == remote_base):
raise PermissionError(_("Security issues with file uploading: path traversal detected"))
try:
hostname, port = conn.host, 22
if ":" in hostname:
hostname, port_str = hostname.split(":")
port = int(port_str)
ssh = paramiko.SSHClient()
ssh.load_system_host_keys()
ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
ssh.connect(hostname=hostname, port=port, username=conn.login, password=conn.passwd)
try:
sftp = ssh.open_sftp()
try:
remote_file = sftp.open(target_temp, "ab")
try:
remote_file.set_pipelined(True)
for chunk_data in file_chunk.chunks():
remote_file.write(chunk_data)
finally:
remote_file.close()
if is_last_chunk:
sftp.rename(target_temp, target_final)
finally:
sftp.close()
finally:
ssh.close()
except Exception as e:
raise Exception(_("SSH upload failed: {}").format(e))
elif conn.conn == CONN_SOCKET:
base_dir = os.path.abspath(path)
target_temp = os.path.abspath(os.path.join(base_dir, f"{clean_name}.part"))
target_final = os.path.abspath(os.path.join(base_dir, clean_name))
if (
os.path.commonpath([base_dir, target_temp]) != base_dir
or os.path.commonpath([base_dir, target_final]) != base_dir
):
raise PermissionError(_("Security issues with file uploading: path traversal detected"))
try:
with open(target_temp, "ab") as f:
for chunk_data in file_chunk.chunks():
f.write(chunk_data)
if is_last_chunk:
if os.path.exists(target_final):
os.remove(target_final)
os.rename(target_temp, target_final)
except FileNotFoundError:
raise Exception(_("File not found. Check the path variable and filename"))
else:
raise Exception(_("Unsupported connection type for file upload."))
@superuser_only
def storage(request, compute_id, pool):
"""
@ -182,6 +186,12 @@ def storage(request, compute_id, pool):
compute.hostname, compute.login, compute.password, compute.type, pool
)
if request.method == "POST" and "iso_upload" in request.POST:
try:
return _handle_iso_upload(request, conn, conn.get_target_path(), compute, pool)
finally:
conn.close()
storages = conn.get_storages()
state = conn.is_active()
try:
@ -230,71 +240,6 @@ def storage(request, compute_id, pool):
)
return redirect(reverse("storage", args=[compute.id, pool]))
# return HttpResponseRedirect(request.get_full_path())
if "iso_upload" in request.POST:
file_chunk = request.FILES.get("file")
if not file_chunk:
return JsonResponse({"error": _("No file chunk was submitted.")}, status=400)
file_name = request.POST.get("file_name", "")
clean_file_name = os.path.basename(file_name).strip()
if not clean_file_name or clean_file_name in (".", "..") or "\x00" in clean_file_name:
return JsonResponse({"error": _("Invalid file name.")}, status=400)
file_name = clean_file_name
chunk_index = int(request.POST.get("chunk_index", 0))
total_chunks = int(request.POST.get("total_chunks", 1))
is_last_chunk = chunk_index == total_chunks - 1
# On first chunk, check if file already exists
if chunk_index == 0:
if file_name in conn.get_volumes():
return JsonResponse({"error": _("ISO image already exists")}, status=400)
# Clean up any partial files from previous failed uploads
if conn.conn == CONN_SOCKET:
base_dir = os.path.abspath(path)
temp_part_file = os.path.abspath(os.path.join(base_dir, f"{file_name}.part"))
if os.path.commonpath([base_dir, temp_part_file]) == base_dir and os.path.exists(temp_part_file):
os.remove(temp_part_file)
elif conn.conn == CONN_SSH:
remote_base = posixpath.normpath(path)
temp_part_file = posixpath.normpath(posixpath.join(remote_base, f"{file_name}.part"))
if temp_part_file.startswith(remote_base + "/") or temp_part_file == remote_base:
try:
hostname, port = conn.host, 22
if ":" in hostname:
hostname, port_str = hostname.split(":")
port = int(port_str)
ssh = paramiko.SSHClient()
ssh.load_system_host_keys()
ssh.set_missing_host_key_policy(paramiko.WarningPolicy())
ssh.connect(hostname=hostname, port=port, username=conn.login, password=conn.passwd)
try:
sftp = ssh.open_sftp()
try:
sftp.remove(temp_part_file)
except FileNotFoundError:
pass # File doesn't exist, which is fine
finally:
sftp.close()
finally:
ssh.close()
except Exception:
# Best effort to clean up, if it fails, let it be.
pass
try:
handle_uploaded_file(conn, path, file_name, file_chunk, is_last_chunk)
if is_last_chunk:
success_msg = _("ISO: %(file)s has been uploaded successfully.") % {"file": file_name}
messages.success(request, success_msg)
return JsonResponse({"success": True, "message": success_msg, "reload": True})
else:
return JsonResponse({"success": True, "message": "Chunk received."})
except Exception as e:
error_msg = str(e)
messages.error(request, error_msg)
return JsonResponse({"error": error_msg}, status=500)
if "cln_volume" in request.POST:
form = CloneImage(request.POST)
if form.is_valid():