printerxpl-forge 6.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nse/README.md +204 -0
- nse/__init__.py +6 -0
- nse/install_nse.py +412 -0
- nse/lib/printerxpl.lua +238 -0
- nse/scripts/cups-info.nse +74 -0
- nse/scripts/cups-queue-info.nse +43 -0
- nse/scripts/hp-printers-cve-2022-1026.nse +121 -0
- nse/scripts/http-device-mac.nse +107 -0
- nse/scripts/http-hp-ilo-info.nse +121 -0
- nse/scripts/http-info-xerox-enum.nse +101 -0
- nse/scripts/http-vuln-cve2022-1026.nse +158 -0
- nse/scripts/lexmark-config.nse +89 -0
- nse/scripts/pjl-ready-message.nse +106 -0
- nse/scripts/printer-banner.nse +217 -0
- nse/scripts/printer-cups-rce.nse +189 -0
- nse/scripts/printer-cve-detect.nse +279 -0
- nse/scripts/printer-discover.nse +205 -0
- nse/scripts/printer-firmware-exposed.nse +219 -0
- nse/scripts/printer-hp-pjl.nse +192 -0
- nse/scripts/printer-http-ews.nse +293 -0
- nse/scripts/printer-ipp-info.nse +235 -0
- nse/scripts/printer-lexmark-ipp.nse +203 -0
- nse/scripts/printer-passback.nse +204 -0
- nse/scripts/printer-pjl-info.nse +146 -0
- nse/scripts/printer-printnightmare.nse +211 -0
- nse/scripts/printer-snmp-info.nse +176 -0
- nse/scripts/printer-vuln-check.nse +256 -0
- nse/scripts/snmp-device-mac.nse +93 -0
- nse/scripts/snmp-info.nse +146 -0
- nse/scripts/snmp-sysdescr.nse +70 -0
- printerxpl_forge-6.2.0.dist-info/METADATA +919 -0
- printerxpl_forge-6.2.0.dist-info/RECORD +97 -0
- printerxpl_forge-6.2.0.dist-info/WHEEL +5 -0
- printerxpl_forge-6.2.0.dist-info/entry_points.txt +4 -0
- printerxpl_forge-6.2.0.dist-info/licenses/LICENSE +21 -0
- printerxpl_forge-6.2.0.dist-info/top_level.txt +4 -0
- src/assets/fonts/gunplay.pfa +1671 -0
- src/assets/fonts/kshandwrt.pfa +315 -0
- src/assets/fonts/laksoner.pfa +2402 -0
- src/assets/fonts/paintcans.pfa +9699 -0
- src/assets/fonts/stencilod.pfa +4076 -0
- src/assets/fonts/takecover.pfa +26138 -0
- src/assets/fonts/topsecret.pfa +6652 -0
- src/assets/fonts/whoa.pfa +773 -0
- src/assets/mibs/HOST-RESOURCES-MIB +1540 -0
- src/assets/mibs/Printer-MIB +4389 -0
- src/assets/mibs/README.md +9 -0
- src/assets/mibs/SNMPv2-MIB +854 -0
- src/assets/overlays/hacker.eps +596 -0
- src/assets/overlays/smiley.eps +214 -0
- src/assets/overlays/smiley2.eps +240 -0
- src/core/attack_orchestrator.py +1025 -0
- src/core/capabilities.py +323 -0
- src/core/destructive_audit.py +430 -0
- src/core/discovery.py +488 -0
- src/core/osdetect.py +74 -0
- src/core/poly_runner.py +579 -0
- src/core/printer.py +1426 -0
- src/main.py +2134 -0
- src/modules/install_printer.py +318 -0
- src/modules/login_bruteforce.py +852 -0
- src/modules/pcl.py +506 -0
- src/modules/pjl.py +3575 -0
- src/modules/print_job.py +1290 -0
- src/modules/ps.py +1102 -0
- src/payloads/__init__.py +98 -0
- src/payloads/assets/overlays/notice.eps +9 -0
- src/protocols/__init__.py +19 -0
- src/protocols/firmware.py +738 -0
- src/protocols/ipp.py +216 -0
- src/protocols/ipp_attacks.py +609 -0
- src/protocols/lpd.py +141 -0
- src/protocols/network_map.py +1004 -0
- src/protocols/raw.py +173 -0
- src/protocols/smb.py +359 -0
- src/protocols/ssrf_pivot.py +427 -0
- src/protocols/storage.py +587 -0
- src/ui/__init__.py +6 -0
- src/ui/interactive.py +742 -0
- src/ui/spinner.py +112 -0
- src/ui/tables.py +132 -0
- src/utils/banner_grabber.py +852 -0
- src/utils/codebook.py +456 -0
- src/utils/config.py +522 -0
- src/utils/cve_loader.py +158 -0
- src/utils/default_creds.py +134 -0
- src/utils/discovery_online.py +1327 -0
- src/utils/exploit_manager.py +805 -0
- src/utils/fuzzer.py +220 -0
- src/utils/helper.py +732 -0
- src/utils/local_printers.py +307 -0
- src/utils/ml_engine.py +491 -0
- src/utils/operators.py +474 -0
- src/utils/ports.py +234 -0
- src/utils/vuln_scanner.py +823 -0
- src/utils/wordlist_loader.py +412 -0
- src/version.py +36 -0
src/protocols/raw.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
RAW Protocol Support for PrinterXPL-Forge
|
|
5
|
+
======================================
|
|
6
|
+
Direct TCP/IP printing on port 9100 (AppSocket / JetDirect).
|
|
7
|
+
|
|
8
|
+
Supports IPv4 and IPv6 transparently.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Author : Andre Henrique (@mrhenrike)
|
|
12
|
+
# GitHub : https://github.com/mrhenrike
|
|
13
|
+
# LinkedIn : https://linkedin.com/in/mrhenrike
|
|
14
|
+
# X/Twitter : https://x.com/mrhenrike
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import socket
|
|
19
|
+
import time
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_address(host: str) -> tuple:
|
|
24
|
+
"""
|
|
25
|
+
Resolve *host* to a (family, addr) pair.
|
|
26
|
+
|
|
27
|
+
Handles:
|
|
28
|
+
- Plain IPv4 addresses / hostnames → AF_INET
|
|
29
|
+
- IPv6 literals (with or without [])→ AF_INET6
|
|
30
|
+
- Dual-stack hostnames → prefer IPv4, fall back to IPv6
|
|
31
|
+
"""
|
|
32
|
+
# Strip brackets from IPv6 literals like [::1]
|
|
33
|
+
host_clean = host.strip('[]')
|
|
34
|
+
|
|
35
|
+
# Attempt getaddrinfo — returns list of (family, type, proto, canonname, sockaddr)
|
|
36
|
+
try:
|
|
37
|
+
infos = socket.getaddrinfo(host_clean, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
38
|
+
except socket.gaierror as exc:
|
|
39
|
+
raise ConnectionError(f"Cannot resolve host '{host}': {exc}") from exc
|
|
40
|
+
|
|
41
|
+
if not infos:
|
|
42
|
+
raise ConnectionError(f"No address found for host '{host}'")
|
|
43
|
+
|
|
44
|
+
# Prefer IPv4 to avoid surprises on single-stack networks
|
|
45
|
+
for info in infos:
|
|
46
|
+
if info[0] == socket.AF_INET:
|
|
47
|
+
return socket.AF_INET, info[4][0]
|
|
48
|
+
|
|
49
|
+
# Fall back to first result (IPv6 or whatever is available)
|
|
50
|
+
info = infos[0]
|
|
51
|
+
return info[0], info[4][0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RAWProtocol:
|
|
55
|
+
"""
|
|
56
|
+
RAW / AppSocket / JetDirect protocol implementation (default port 9100).
|
|
57
|
+
|
|
58
|
+
This is the most common protocol for sending PJL, PostScript and PCL
|
|
59
|
+
payloads directly to a network printer.
|
|
60
|
+
|
|
61
|
+
Supports both IPv4 and IPv6.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
DEFAULT_PORT = 9100
|
|
65
|
+
|
|
66
|
+
def __init__(self, host: str, port: Optional[int] = None, timeout: float = 30.0):
|
|
67
|
+
self.host = host
|
|
68
|
+
self.port = port or self.DEFAULT_PORT
|
|
69
|
+
self.timeout = timeout
|
|
70
|
+
self.sock: Optional[socket.socket] = None
|
|
71
|
+
self._family: int = socket.AF_INET
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
def connect(self) -> bool:
|
|
75
|
+
"""
|
|
76
|
+
Open a TCP connection to the printer.
|
|
77
|
+
|
|
78
|
+
Returns True on success, False on failure.
|
|
79
|
+
Auto-selects IPv4 or IPv6 based on host resolution.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
family, addr = _resolve_address(self.host)
|
|
83
|
+
self._family = family
|
|
84
|
+
self.sock = socket.socket(family, socket.SOCK_STREAM)
|
|
85
|
+
self.sock.settimeout(self.timeout)
|
|
86
|
+
self.sock.connect((addr, self.port))
|
|
87
|
+
return True
|
|
88
|
+
except Exception:
|
|
89
|
+
if self.sock:
|
|
90
|
+
try:
|
|
91
|
+
self.sock.close()
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
self.sock = None
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
def send(self, data: bytes | str) -> None:
|
|
98
|
+
"""Send raw bytes (or a string which will be UTF-8 encoded)."""
|
|
99
|
+
if not self.sock:
|
|
100
|
+
raise ConnectionError("Not connected — call connect() first")
|
|
101
|
+
if isinstance(data, str):
|
|
102
|
+
data = data.encode('utf-8', errors='replace')
|
|
103
|
+
self.sock.sendall(data)
|
|
104
|
+
|
|
105
|
+
def recv(self, size: int = 4096, timeout: Optional[float] = None) -> bytes:
|
|
106
|
+
"""
|
|
107
|
+
Receive up to *size* bytes.
|
|
108
|
+
|
|
109
|
+
*timeout* overrides the socket timeout for this single call.
|
|
110
|
+
"""
|
|
111
|
+
if not self.sock:
|
|
112
|
+
raise ConnectionError("Not connected")
|
|
113
|
+
if timeout is not None:
|
|
114
|
+
old = self.sock.gettimeout()
|
|
115
|
+
self.sock.settimeout(timeout)
|
|
116
|
+
try:
|
|
117
|
+
return self.sock.recv(size)
|
|
118
|
+
finally:
|
|
119
|
+
if timeout is not None:
|
|
120
|
+
self.sock.settimeout(old)
|
|
121
|
+
|
|
122
|
+
def recv_all(self, timeout: float = 2.0) -> bytes:
|
|
123
|
+
"""
|
|
124
|
+
Drain all available data with a short read-loop until the printer
|
|
125
|
+
stops sending (identified by a read timeout).
|
|
126
|
+
"""
|
|
127
|
+
buf = b''
|
|
128
|
+
self.sock.settimeout(timeout)
|
|
129
|
+
try:
|
|
130
|
+
while True:
|
|
131
|
+
chunk = self.sock.recv(4096)
|
|
132
|
+
if not chunk:
|
|
133
|
+
break
|
|
134
|
+
buf += chunk
|
|
135
|
+
except (socket.timeout, BlockingIOError):
|
|
136
|
+
pass
|
|
137
|
+
return buf
|
|
138
|
+
|
|
139
|
+
def close(self) -> None:
|
|
140
|
+
"""Close the underlying socket."""
|
|
141
|
+
if self.sock:
|
|
142
|
+
try:
|
|
143
|
+
self.sock.close()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
self.sock = None
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def is_connected(self) -> bool:
|
|
150
|
+
"""Return True if the socket is currently open."""
|
|
151
|
+
return self.sock is not None
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def is_ipv6(self) -> bool:
|
|
155
|
+
"""Return True if the active connection uses IPv6."""
|
|
156
|
+
return self._family == socket.AF_INET6
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
def __enter__(self) -> 'RAWProtocol':
|
|
160
|
+
self.connect()
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
def __exit__(self, *_) -> None:
|
|
164
|
+
self.close()
|
|
165
|
+
|
|
166
|
+
def __repr__(self) -> str:
|
|
167
|
+
proto = 'IPv6' if self.is_ipv6 else 'IPv4'
|
|
168
|
+
state = 'connected' if self.is_connected else 'disconnected'
|
|
169
|
+
return f"<RAWProtocol {self.host}:{self.port} [{proto}] {state}>"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Backward-compatibility alias
|
|
173
|
+
RawProtocol = RAWProtocol
|
src/protocols/smb.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
SMB Protocol Support for PrinterXPL-Forge
|
|
5
|
+
========================================
|
|
6
|
+
Server Message Block printing on ports 445 / 139.
|
|
7
|
+
|
|
8
|
+
Two backends are tried in priority order:
|
|
9
|
+
1. pysmb (pure-Python, SMB1 / SMB2)
|
|
10
|
+
2. smbclient system binary (fallback via subprocess)
|
|
11
|
+
|
|
12
|
+
Both IPv4 and IPv6 are supported.
|
|
13
|
+
|
|
14
|
+
Usage example::
|
|
15
|
+
|
|
16
|
+
with SMBProtocol('192.168.1.5', share='print$') as smb:
|
|
17
|
+
if smb.connect():
|
|
18
|
+
shares = smb.list_shares()
|
|
19
|
+
printers = smb.list_printers()
|
|
20
|
+
smb.print_file('payload.ps', b'%!PS ...')
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# Author : Andre Henrique (@mrhenrike)
|
|
24
|
+
# GitHub : https://github.com/mrhenrike
|
|
25
|
+
# LinkedIn : https://linkedin.com/in/mrhenrike
|
|
26
|
+
# X/Twitter : https://x.com/mrhenrike
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import io
|
|
31
|
+
import os
|
|
32
|
+
import shutil
|
|
33
|
+
import socket
|
|
34
|
+
import subprocess
|
|
35
|
+
import tempfile
|
|
36
|
+
import uuid
|
|
37
|
+
from typing import Dict, List, Optional
|
|
38
|
+
|
|
39
|
+
# ── Optional pysmb backend ───────────────────────────────────────────────────
|
|
40
|
+
try:
|
|
41
|
+
from smb.SMBConnection import SMBConnection as _SMBConnection
|
|
42
|
+
from smb.smb_structs import OperationFailure as _SMBOpFail
|
|
43
|
+
_PYSMB_AVAILABLE = True
|
|
44
|
+
except ImportError:
|
|
45
|
+
_PYSMB_AVAILABLE = False
|
|
46
|
+
_SMBOpFail = Exception # type: ignore
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SMBProtocol:
|
|
50
|
+
"""
|
|
51
|
+
SMB protocol implementation for printer enumeration and file printing.
|
|
52
|
+
|
|
53
|
+
Supports:
|
|
54
|
+
- Share enumeration
|
|
55
|
+
- Printer share discovery
|
|
56
|
+
- File printing via SMB
|
|
57
|
+
- SMB printer fingerprinting (OS, machine name)
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
DEFAULT_PORT = 445
|
|
61
|
+
ALT_PORT = 139
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
host: str,
|
|
66
|
+
port: Optional[int] = None,
|
|
67
|
+
timeout: float = 30.0,
|
|
68
|
+
share: str = 'print$',
|
|
69
|
+
username: str = 'guest',
|
|
70
|
+
password: str = '',
|
|
71
|
+
domain: str = '',
|
|
72
|
+
):
|
|
73
|
+
self.host = host.strip('[]')
|
|
74
|
+
self.port = port or self.DEFAULT_PORT
|
|
75
|
+
self.timeout = timeout
|
|
76
|
+
self.share = share
|
|
77
|
+
self.username = username
|
|
78
|
+
self.password = password
|
|
79
|
+
self.domain = domain
|
|
80
|
+
|
|
81
|
+
self._conn: Optional[_SMBConnection] = None
|
|
82
|
+
self._connected: bool = False
|
|
83
|
+
self._server_name: str = ''
|
|
84
|
+
self._os_info: str = ''
|
|
85
|
+
self._backend: str = 'none'
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Connection
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def connect(self) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Attempt SMB connection using pysmb (preferred) or a TCP probe.
|
|
94
|
+
|
|
95
|
+
Returns True on success, False on failure.
|
|
96
|
+
"""
|
|
97
|
+
if _PYSMB_AVAILABLE:
|
|
98
|
+
return self._connect_pysmb()
|
|
99
|
+
return self._connect_raw_tcp()
|
|
100
|
+
|
|
101
|
+
def _connect_pysmb(self) -> bool:
|
|
102
|
+
"""Connect using pysmb library (SMB1/SMB2)."""
|
|
103
|
+
client_name = 'PrinterXPL-Forge-' + uuid.uuid4().hex[:6]
|
|
104
|
+
try:
|
|
105
|
+
# Resolve server NetBIOS name (use hostname or IP as fallback)
|
|
106
|
+
server_name = self._resolve_netbios_name() or self.host
|
|
107
|
+
self._conn = _SMBConnection(
|
|
108
|
+
self.username,
|
|
109
|
+
self.password,
|
|
110
|
+
client_name,
|
|
111
|
+
server_name,
|
|
112
|
+
domain = self.domain,
|
|
113
|
+
use_ntlm_v2 = True,
|
|
114
|
+
is_direct_tcp = (self.port == 445),
|
|
115
|
+
)
|
|
116
|
+
connected = self._conn.connect(self.host, self.port, timeout=int(self.timeout))
|
|
117
|
+
if connected:
|
|
118
|
+
self._connected = True
|
|
119
|
+
self._backend = 'pysmb'
|
|
120
|
+
self._server_name = server_name
|
|
121
|
+
return connected
|
|
122
|
+
except Exception:
|
|
123
|
+
self._conn = None
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
def _connect_raw_tcp(self) -> bool:
|
|
127
|
+
"""Fallback: just probe whether the port is open."""
|
|
128
|
+
for port in (self.port, self.ALT_PORT):
|
|
129
|
+
try:
|
|
130
|
+
s = socket.create_connection((self.host, port), timeout=self.timeout)
|
|
131
|
+
s.close()
|
|
132
|
+
self.port = port
|
|
133
|
+
self._backend = 'tcp-probe'
|
|
134
|
+
self._connected = True
|
|
135
|
+
return True
|
|
136
|
+
except OSError:
|
|
137
|
+
continue
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
def _resolve_netbios_name(self) -> str:
|
|
141
|
+
"""Try to obtain the remote machine's NetBIOS / hostname."""
|
|
142
|
+
try:
|
|
143
|
+
return socket.gethostbyaddr(self.host)[0].split('.')[0].upper()
|
|
144
|
+
except Exception:
|
|
145
|
+
return ''
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Enumeration
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def list_shares(self) -> List[Dict[str, str]]:
|
|
152
|
+
"""
|
|
153
|
+
List all SMB shares on the target.
|
|
154
|
+
|
|
155
|
+
Returns a list of dicts with keys: name, type, comments.
|
|
156
|
+
"""
|
|
157
|
+
if not self._connected:
|
|
158
|
+
raise RuntimeError("Not connected — call connect() first")
|
|
159
|
+
|
|
160
|
+
if self._conn:
|
|
161
|
+
try:
|
|
162
|
+
raw = self._conn.listShares()
|
|
163
|
+
return [
|
|
164
|
+
{
|
|
165
|
+
'name': s.name,
|
|
166
|
+
'type': str(s.type),
|
|
167
|
+
'comments': s.comments,
|
|
168
|
+
}
|
|
169
|
+
for s in raw
|
|
170
|
+
]
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
raise RuntimeError(f"listShares failed: {exc}") from exc
|
|
173
|
+
|
|
174
|
+
# No pysmb — try smbclient binary
|
|
175
|
+
return self._smbclient_list_shares()
|
|
176
|
+
|
|
177
|
+
def list_printers(self) -> List[Dict[str, str]]:
|
|
178
|
+
"""
|
|
179
|
+
Filter shares that are printer shares (type 0x00000001 in SMB).
|
|
180
|
+
|
|
181
|
+
Returns a list of printer share dicts.
|
|
182
|
+
"""
|
|
183
|
+
shares = self.list_shares()
|
|
184
|
+
printers = []
|
|
185
|
+
for s in shares:
|
|
186
|
+
# pysmb returns type as string; '1' or '65536' (print$ uses type 3)
|
|
187
|
+
name = s.get('name', '').lower()
|
|
188
|
+
if ('print' in name or s.get('type', '') in ('1', '65536', '3')):
|
|
189
|
+
printers.append(s)
|
|
190
|
+
return printers
|
|
191
|
+
|
|
192
|
+
def list_files(self, path: str = '/') -> List[str]:
|
|
193
|
+
"""List files in a share path (requires an active pysmb connection)."""
|
|
194
|
+
if not self._conn:
|
|
195
|
+
raise RuntimeError("pysmb backend required for file listing")
|
|
196
|
+
try:
|
|
197
|
+
entries = self._conn.listPath(self.share, path)
|
|
198
|
+
return [e.filename for e in entries if e.filename not in ('.', '..')]
|
|
199
|
+
except _SMBOpFail as exc:
|
|
200
|
+
raise PermissionError(f"Cannot list {self.share}{path}: {exc}") from exc
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# Printing
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def print_file(
|
|
207
|
+
self,
|
|
208
|
+
remote_filename: str,
|
|
209
|
+
data: bytes,
|
|
210
|
+
print_share: Optional[str] = None,
|
|
211
|
+
) -> bool:
|
|
212
|
+
"""
|
|
213
|
+
Send *data* as a print job to the SMB printer share.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
remote_filename: Spool filename on the share (e.g. 'job.ps').
|
|
217
|
+
data: Raw bytes to send (PostScript, PCL, etc.).
|
|
218
|
+
print_share: Share name to use; defaults to self.share.
|
|
219
|
+
|
|
220
|
+
Returns True on success.
|
|
221
|
+
"""
|
|
222
|
+
share = print_share or self.share
|
|
223
|
+
|
|
224
|
+
if self._conn and self._backend == 'pysmb':
|
|
225
|
+
return self._pysmb_print(share, remote_filename, data)
|
|
226
|
+
|
|
227
|
+
return self._smbclient_print(share, data)
|
|
228
|
+
|
|
229
|
+
def _pysmb_print(self, share: str, filename: str, data: bytes) -> bool:
|
|
230
|
+
"""Upload data to printer share using pysmb."""
|
|
231
|
+
try:
|
|
232
|
+
buf = io.BytesIO(data)
|
|
233
|
+
self._conn.storeFile(share, filename, buf)
|
|
234
|
+
return True
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
raise RuntimeError(f"SMB store failed: {exc}") from exc
|
|
237
|
+
|
|
238
|
+
def _smbclient_print(self, share: str, data: bytes) -> bool:
|
|
239
|
+
"""Print via smbclient binary (subprocess fallback)."""
|
|
240
|
+
smbclient = shutil.which('smbclient')
|
|
241
|
+
if not smbclient:
|
|
242
|
+
raise RuntimeError(
|
|
243
|
+
"pysmb not available and smbclient not found. "
|
|
244
|
+
"Install with: apt install smbclient | brew install samba"
|
|
245
|
+
)
|
|
246
|
+
# Write data to a temp file, then spool via smbclient
|
|
247
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix='.prn') as tmp:
|
|
248
|
+
tmp.write(data)
|
|
249
|
+
tmp_path = tmp.name
|
|
250
|
+
try:
|
|
251
|
+
cmd = [smbclient, f'//{self.host}/{share}',
|
|
252
|
+
'-U', f'{self.username}%{self.password}',
|
|
253
|
+
'-c', f'print {tmp_path}']
|
|
254
|
+
result = subprocess.run(cmd, capture_output=True, text=True,
|
|
255
|
+
timeout=self.timeout)
|
|
256
|
+
if result.returncode != 0:
|
|
257
|
+
raise RuntimeError(f"smbclient error: {result.stderr.strip()}")
|
|
258
|
+
return True
|
|
259
|
+
finally:
|
|
260
|
+
try:
|
|
261
|
+
os.unlink(tmp_path)
|
|
262
|
+
except OSError:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
# ------------------------------------------------------------------
|
|
266
|
+
# Fingerprinting
|
|
267
|
+
# ------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def get_info(self) -> Dict[str, str]:
|
|
270
|
+
"""
|
|
271
|
+
Return basic SMB session information.
|
|
272
|
+
|
|
273
|
+
Includes: host, port, backend, connected, server_name, os_info, share.
|
|
274
|
+
"""
|
|
275
|
+
return {
|
|
276
|
+
'host': self.host,
|
|
277
|
+
'port': str(self.port),
|
|
278
|
+
'backend': self._backend,
|
|
279
|
+
'connected': str(self._connected),
|
|
280
|
+
'server_name': self._server_name,
|
|
281
|
+
'os_info': self._os_info,
|
|
282
|
+
'share': self.share,
|
|
283
|
+
'pysmb': str(_PYSMB_AVAILABLE),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
def fingerprint(self) -> str:
|
|
287
|
+
"""
|
|
288
|
+
Attempt to obtain OS/machine fingerprint from the SMB session.
|
|
289
|
+
|
|
290
|
+
Returns a human-readable string.
|
|
291
|
+
"""
|
|
292
|
+
if self._conn and self._backend == 'pysmb':
|
|
293
|
+
try:
|
|
294
|
+
# pysmb stores server OS info after a successful connection
|
|
295
|
+
info = self._conn.getAttributes(self.share, '/')
|
|
296
|
+
return f"Server: {self._server_name}"
|
|
297
|
+
except Exception:
|
|
298
|
+
pass
|
|
299
|
+
return f"SMB host {self.host}:{self.port} [{self._backend}]"
|
|
300
|
+
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
# Context manager
|
|
303
|
+
# ------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
def close(self) -> None:
|
|
306
|
+
"""Close the SMB connection."""
|
|
307
|
+
if self._conn:
|
|
308
|
+
try:
|
|
309
|
+
self._conn.close()
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
312
|
+
self._conn = None
|
|
313
|
+
self._connected = False
|
|
314
|
+
|
|
315
|
+
def __enter__(self) -> 'SMBProtocol':
|
|
316
|
+
self.connect()
|
|
317
|
+
return self
|
|
318
|
+
|
|
319
|
+
def __exit__(self, *_) -> None:
|
|
320
|
+
self.close()
|
|
321
|
+
|
|
322
|
+
def __repr__(self) -> str:
|
|
323
|
+
state = 'connected' if self._connected else 'disconnected'
|
|
324
|
+
return (
|
|
325
|
+
f"<SMBProtocol {self.host}:{self.port} "
|
|
326
|
+
f"share={self.share!r} backend={self._backend!r} {state}>"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ── Module-level convenience function ─────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
def print_via_smb(
|
|
333
|
+
host: str,
|
|
334
|
+
share: str,
|
|
335
|
+
data: bytes,
|
|
336
|
+
username: str = 'guest',
|
|
337
|
+
password: str = '',
|
|
338
|
+
port: int = 445,
|
|
339
|
+
timeout: float = 30.0,
|
|
340
|
+
) -> bool:
|
|
341
|
+
"""
|
|
342
|
+
One-shot SMB print helper.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
host: Printer IP or hostname.
|
|
346
|
+
share: SMB printer share name (e.g. 'print$').
|
|
347
|
+
data: Raw bytes to spool (PostScript, PJL, etc.).
|
|
348
|
+
username: SMB username (default: guest).
|
|
349
|
+
password: SMB password (default: empty).
|
|
350
|
+
port: SMB port (default: 445).
|
|
351
|
+
timeout: Connection timeout in seconds.
|
|
352
|
+
|
|
353
|
+
Returns True on success.
|
|
354
|
+
"""
|
|
355
|
+
with SMBProtocol(host, port=port, timeout=timeout,
|
|
356
|
+
share=share, username=username, password=password) as smb:
|
|
357
|
+
if not smb._connected:
|
|
358
|
+
raise ConnectionError(f"Cannot connect to SMB on {host}:{port}")
|
|
359
|
+
return smb.print_file('printjob.prn', data)
|