first commit
This commit is contained in:
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
aiohttp
|
||||||
|
pyautogui
|
||||||
|
Pillow
|
||||||
|
mss
|
||||||
21
setup.sh
Executable file
21
setup.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
# 1) OS deps for PyAutoGUI (Linux)
|
||||||
|
sudo apt update && sudo apt install -y python3-tk python3-dev scrot
|
||||||
|
# 2) Python deps (run inside your httprd folder)
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 4) From viewer machine, open in browser:
|
||||||
|
# http://<SERVER-IP>:7417
|
||||||
|
hostname -I | awk '{print $1}' # (shows server IP)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
sudo ufw allow 7417/tcp # open firewall if needed
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
python httprd.py
|
||||||
|
|
||||||
|
# 3) Run the server (set your passwords)
|
||||||
|
python3 httprd.py --port 7417 --password 'CONTROL123' --view_password 'VIEW123'
|
||||||
57
src/build.py
Normal file
57
src/build.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Build two files into one
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import gzip
|
||||||
|
|
||||||
|
|
||||||
|
def replace_template(src: str, template_name: str, new_text: str):
|
||||||
|
"""
|
||||||
|
Replace tag with structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
# <tempalte:template_name>
|
||||||
|
'something to replace'
|
||||||
|
# </template:template_name>
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
tps = f'# <template:{ template_name }>'
|
||||||
|
tpe = f'# </template:{ template_name }>'
|
||||||
|
|
||||||
|
ind_start = src.index(tps)
|
||||||
|
ind_end = src.index(tpe) + len(tpe)
|
||||||
|
|
||||||
|
return f'{ src[:ind_start] }{ new_text }{ src[ind_end:] }'
|
||||||
|
|
||||||
|
|
||||||
|
with open('index.html', 'r', encoding='utf-8') as f:
|
||||||
|
page = f.read()
|
||||||
|
|
||||||
|
with open('httprd.py', 'r', encoding='utf-8') as f:
|
||||||
|
httprd = f.read()
|
||||||
|
|
||||||
|
|
||||||
|
page = page.replace('\t', '')
|
||||||
|
lines = []
|
||||||
|
for l in page.split('\n'):
|
||||||
|
l = l.strip()
|
||||||
|
|
||||||
|
# Despace
|
||||||
|
for _ in range(8):
|
||||||
|
l = l.replace(' ', ' ')
|
||||||
|
|
||||||
|
if len(l) == 0:
|
||||||
|
continue
|
||||||
|
if l.startswith('//'):
|
||||||
|
continue
|
||||||
|
lines.append(l)
|
||||||
|
page = '\n'.join(lines)
|
||||||
|
|
||||||
|
page = base64.b85encode(gzip.compress(page.encode('utf-8'))).decode()
|
||||||
|
|
||||||
|
httprd = replace_template(httprd, 'INDEX_CONTENT', f'''INDEX_CONTENT = gzip.decompress(base64.b85decode('{ page }'.encode())).decode('utf-8')''')
|
||||||
|
httprd = replace_template(httprd, 'get__root', f'''return aiohttp.web.Response(body=INDEX_CONTENT, content_type='text/html', status=200, charset='utf-8')''')
|
||||||
|
|
||||||
|
|
||||||
|
with open('./../httprd.py', 'w', encoding='utf-8') as f:
|
||||||
|
f.write(httprd)
|
||||||
411
src/httprd.py
Normal file
411
src/httprd.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
# httprd: web-based remote desktop
|
||||||
|
# Copyright (C) 2022-2023 bitrate16
|
||||||
|
#
|
||||||
|
# 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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
VERSION = '4.2'
|
||||||
|
|
||||||
|
import json
|
||||||
|
import aiohttp
|
||||||
|
import aiohttp.web
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import gzip
|
||||||
|
import PIL
|
||||||
|
import PIL.Image
|
||||||
|
import PIL.ImageGrab
|
||||||
|
import PIL.ImageChops
|
||||||
|
import pyautogui
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cStringIO import StringIO as BytesIO
|
||||||
|
except ImportError:
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
# Const config
|
||||||
|
DOWNSAMPLE = PIL.Image.BILINEAR
|
||||||
|
# Minimal amount of partial frames to be sent before sending full repaint frame to avoid fallback to full repaint on long delay channels
|
||||||
|
MIN_PARTIAL_FRAMES_BEFORE_FULL_REPAINT = 60
|
||||||
|
# Minimal amount of empty frames to be sent before sending full repaint frame to avoid fallback to full repaint on long delay channels
|
||||||
|
MIN_EMPTY_FRAMES_BEFORE_FULL_REPAINT = 120
|
||||||
|
|
||||||
|
# Input event types
|
||||||
|
INPUT_EVENT_MOUSE_MOVE = 0
|
||||||
|
INPUT_EVENT_MOUSE_DOWN = 1
|
||||||
|
INPUT_EVENT_MOUSE_UP = 2
|
||||||
|
INPUT_EVENT_MOUSE_SCROLL = 3
|
||||||
|
INPUT_EVENT_KEY_DOWN = 4
|
||||||
|
INPUT_EVENT_KEY_UP = 5
|
||||||
|
|
||||||
|
# Failsafe disable
|
||||||
|
pyautogui.FAILSAFE = False
|
||||||
|
|
||||||
|
# Args
|
||||||
|
args = {}
|
||||||
|
|
||||||
|
# Real resolution
|
||||||
|
real_width, real_height = 0, 0
|
||||||
|
|
||||||
|
# Webapp
|
||||||
|
app: aiohttp.web.Application
|
||||||
|
|
||||||
|
|
||||||
|
def decode_int8(data):
|
||||||
|
return int.from_bytes(data[0:1], 'little')
|
||||||
|
|
||||||
|
def decode_int16(data):
|
||||||
|
return int.from_bytes(data[0:2], 'little')
|
||||||
|
|
||||||
|
def decode_int24(data):
|
||||||
|
return int.from_bytes(data[0:3], 'little')
|
||||||
|
|
||||||
|
def encode_int8(i):
|
||||||
|
return int.to_bytes(i, 1, 'little')
|
||||||
|
|
||||||
|
def encode_int16(i):
|
||||||
|
return int.to_bytes(i, 2, 'little')
|
||||||
|
|
||||||
|
def encode_int24(i):
|
||||||
|
return int.to_bytes(i, 3, 'little')
|
||||||
|
|
||||||
|
def dump_bytes_dec(data):
|
||||||
|
for i in range(len(data)):
|
||||||
|
print(data[i], end=' ')
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
async def get__connect_input_ws(request: aiohttp.web.Request) -> aiohttp.web.StreamResponse:
|
||||||
|
"""
|
||||||
|
WebSocket endpoint for input & control data stream
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
access = (args.password == request.query.get('password', '').strip())
|
||||||
|
|
||||||
|
# Log request
|
||||||
|
now = datetime.now()
|
||||||
|
now = now.strftime("%d.%m.%Y-%H:%M:%S")
|
||||||
|
print(f'[{ now }] { request.remote } { request.method } [{ "INPUT" if access else "NO ACCESS" }] { request.path_qs }')
|
||||||
|
|
||||||
|
# Open socket
|
||||||
|
ws = aiohttp.web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
|
||||||
|
# Close with error code on no access
|
||||||
|
if not access:
|
||||||
|
await ws.close(code=4001, message=b'Unauthorized')
|
||||||
|
return ws
|
||||||
|
|
||||||
|
# Track pressed key state for future reset on disconnect
|
||||||
|
state_keys = {}
|
||||||
|
|
||||||
|
def release_keys():
|
||||||
|
for k in state_keys.keys():
|
||||||
|
if state_keys[k]:
|
||||||
|
pyautogui.keyUp(k)
|
||||||
|
|
||||||
|
def update_key_state(key, state):
|
||||||
|
state_keys[key] = state
|
||||||
|
|
||||||
|
# Read stream
|
||||||
|
async def async_worker():
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
# Reply to requests
|
||||||
|
async for msg in ws:
|
||||||
|
|
||||||
|
# Receive input data
|
||||||
|
if msg.type == aiohttp.WSMsgType.BINARY:
|
||||||
|
try:
|
||||||
|
|
||||||
|
# Drop on invalid packet
|
||||||
|
if len(msg.data) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse params
|
||||||
|
packet_type = decode_int8(msg.data[0:1])
|
||||||
|
payload = msg.data[1:]
|
||||||
|
|
||||||
|
# Input request
|
||||||
|
if packet_type == 0x03:
|
||||||
|
|
||||||
|
# Unpack events data
|
||||||
|
data = json.loads(bytes.decode(payload, encoding='ascii'))
|
||||||
|
|
||||||
|
# Iterate events
|
||||||
|
for event in data:
|
||||||
|
if event[0] == INPUT_EVENT_MOUSE_MOVE: # mouse position
|
||||||
|
mouse_x = max(0, min(real_width, event[1]))
|
||||||
|
mouse_y = max(0, min(real_height, event[2]))
|
||||||
|
|
||||||
|
pyautogui.moveTo(mouse_x, mouse_y)
|
||||||
|
elif event[0] == INPUT_EVENT_MOUSE_DOWN: # mouse down
|
||||||
|
mouse_x = max(0, min(real_width, event[1]))
|
||||||
|
mouse_y = max(0, min(real_height, event[2]))
|
||||||
|
button = event[3]
|
||||||
|
|
||||||
|
# Allow only left, middle, right
|
||||||
|
if button < 0 or button > 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pyautogui.mouseDown(mouse_x, mouse_y, button=[ 'left', 'middle', 'right' ][button])
|
||||||
|
elif event[0] == INPUT_EVENT_MOUSE_UP: # mouse up
|
||||||
|
mouse_x = max(0, min(real_width, event[1]))
|
||||||
|
mouse_y = max(0, min(real_height, event[2]))
|
||||||
|
button = event[3]
|
||||||
|
|
||||||
|
# Allow only left, middle, right
|
||||||
|
if button < 0 or button > 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pyautogui.mouseUp(mouse_x, mouse_y, button=[ 'left', 'middle', 'right' ][button])
|
||||||
|
elif event[0] == INPUT_EVENT_MOUSE_SCROLL: # mouse scroll
|
||||||
|
mouse_x = max(0, min(real_width, event[1]))
|
||||||
|
mouse_y = max(0, min(real_height, event[2]))
|
||||||
|
dy = int(event[3])
|
||||||
|
|
||||||
|
pyautogui.scroll(dy, mouse_x, mouse_y)
|
||||||
|
elif event[0] == INPUT_EVENT_KEY_DOWN: # keypress
|
||||||
|
keycode = event[1]
|
||||||
|
|
||||||
|
pyautogui.keyDown(keycode)
|
||||||
|
update_key_state(keycode, True)
|
||||||
|
elif event[0] == INPUT_EVENT_KEY_UP: # keypress
|
||||||
|
keycode = event[1]
|
||||||
|
|
||||||
|
pyautogui.keyUp(keycode)
|
||||||
|
update_key_state(keycode, False)
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||||
|
print(f'ws connection closed with exception { ws.exception() }')
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
await async_worker()
|
||||||
|
|
||||||
|
# Release stuck keys
|
||||||
|
release_keys()
|
||||||
|
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
async def get__connect_view_ws(request: aiohttp.web.Request) -> aiohttp.web.StreamResponse:
|
||||||
|
"""
|
||||||
|
WebSocket endpoint for frame stream
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
access = (args.password == request.query.get('password', '').strip()) or (args.view_password == request.query.get('password', '').strip())
|
||||||
|
|
||||||
|
# Log request
|
||||||
|
now = datetime.now()
|
||||||
|
now = now.strftime("%d.%m.%Y-%H:%M:%S")
|
||||||
|
print(f'[{ now }] { request.remote } { request.method } [{ "VIEW" if access else "NO ACCESS" }] { request.path_qs }')
|
||||||
|
|
||||||
|
# Open socket
|
||||||
|
ws = aiohttp.web.WebSocketResponse()
|
||||||
|
await ws.prepare(request)
|
||||||
|
|
||||||
|
# Close with error code on no access
|
||||||
|
if not access:
|
||||||
|
await ws.close(code=4001, message=b'Unauthorized')
|
||||||
|
return ws
|
||||||
|
|
||||||
|
# Frame buffer
|
||||||
|
buffer = BytesIO()
|
||||||
|
|
||||||
|
# Read stream
|
||||||
|
async def async_worker():
|
||||||
|
|
||||||
|
# Last screen frame
|
||||||
|
last_frame = None
|
||||||
|
# Track count of partial frames send since last full repaint frame send and prevent firing full frames on low internet
|
||||||
|
partial_frames_since_last_full_repaint_frame = 0
|
||||||
|
# Track count of empty frames send since last full repaint frame send and prevent firing full frames on low internet
|
||||||
|
empty_frames_since_last_full_repaint_frame = 0
|
||||||
|
|
||||||
|
# Store remote viewport size to force-push full repaint
|
||||||
|
viewport_width = 0
|
||||||
|
viewport_height = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
# Reply to requests
|
||||||
|
async for msg in ws:
|
||||||
|
|
||||||
|
# Receive input data
|
||||||
|
if msg.type == aiohttp.WSMsgType.BINARY:
|
||||||
|
try:
|
||||||
|
|
||||||
|
# Drop on invalid packet
|
||||||
|
if len(msg.data) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse params
|
||||||
|
packet_type = decode_int8(msg.data[0:1])
|
||||||
|
payload = msg.data[1:]
|
||||||
|
|
||||||
|
# Frame request
|
||||||
|
if packet_type == 0x01:
|
||||||
|
req_viewport_width = decode_int16(payload[0:2])
|
||||||
|
req_viewport_height = decode_int16(payload[2:4])
|
||||||
|
quality = decode_int8(payload[4:5])
|
||||||
|
|
||||||
|
# Grab frame
|
||||||
|
if args.fullscreen:
|
||||||
|
image = PIL.ImageGrab.grab(bbox=None, include_layered_windows=False, all_screens=True)
|
||||||
|
else:
|
||||||
|
image = PIL.ImageGrab.grab()
|
||||||
|
|
||||||
|
# Real dimensions
|
||||||
|
global real_width, real_height
|
||||||
|
real_width, real_height = image.width, image.height
|
||||||
|
|
||||||
|
# Resize
|
||||||
|
if image.width > req_viewport_width or image.height > req_viewport_height:
|
||||||
|
image.thumbnail((req_viewport_width, req_viewport_height), DOWNSAMPLE)
|
||||||
|
|
||||||
|
# Write header: frame response
|
||||||
|
buffer.seek(0)
|
||||||
|
buffer.write(encode_int8(0x02))
|
||||||
|
buffer.write(encode_int16(real_width))
|
||||||
|
buffer.write(encode_int16(real_height))
|
||||||
|
|
||||||
|
# Compare frames
|
||||||
|
if last_frame is not None:
|
||||||
|
diff_bbox = PIL.ImageChops.difference(last_frame, image).getbbox()
|
||||||
|
|
||||||
|
# Check if this is first frame of should force repaint full surface
|
||||||
|
if last_frame is None or \
|
||||||
|
viewport_width != req_viewport_width or \
|
||||||
|
viewport_height != req_viewport_height or \
|
||||||
|
partial_frames_since_last_full_repaint_frame > MIN_PARTIAL_FRAMES_BEFORE_FULL_REPAINT or \
|
||||||
|
empty_frames_since_last_full_repaint_frame > MIN_EMPTY_FRAMES_BEFORE_FULL_REPAINT:
|
||||||
|
buffer.write(encode_int8(0x01))
|
||||||
|
|
||||||
|
# Write body
|
||||||
|
image = image.convert('RGB')
|
||||||
|
image.save(fp=buffer, format='JPEG', quality=quality)
|
||||||
|
last_frame = image
|
||||||
|
|
||||||
|
viewport_width = req_viewport_width
|
||||||
|
viewport_height = req_viewport_height
|
||||||
|
partial_frames_since_last_full_repaint_frame = 0
|
||||||
|
empty_frames_since_last_full_repaint_frame = 0
|
||||||
|
|
||||||
|
# Send nop
|
||||||
|
elif diff_bbox is None :
|
||||||
|
buffer.write(encode_int8(0x00))
|
||||||
|
empty_frames_since_last_full_repaint_frame += 1
|
||||||
|
|
||||||
|
# Send partial repaint region
|
||||||
|
else:
|
||||||
|
buffer.write(encode_int8(0x02))
|
||||||
|
buffer.write(encode_int16(diff_bbox[0])) # crop_x
|
||||||
|
buffer.write(encode_int16(diff_bbox[1])) # crop_y
|
||||||
|
|
||||||
|
# Write body
|
||||||
|
cropped = image.crop(diff_bbox)
|
||||||
|
cropped = cropped.convert('RGB')
|
||||||
|
cropped.save(fp=buffer, format='JPEG', quality=quality)
|
||||||
|
last_frame = image
|
||||||
|
partial_frames_since_last_full_repaint_frame += 1
|
||||||
|
|
||||||
|
buflen = buffer.tell()
|
||||||
|
buffer.seek(0)
|
||||||
|
mbytes = buffer.read(buflen)
|
||||||
|
buffer.seek(0)
|
||||||
|
|
||||||
|
await ws.send_bytes(mbytes)
|
||||||
|
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||||||
|
print(f'ws connection closed with exception { ws.exception() }')
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
await async_worker()
|
||||||
|
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
# Encoded page hoes here
|
||||||
|
# <template:INDEX_CONTENT>
|
||||||
|
# </template:INDEX_CONTENT>
|
||||||
|
|
||||||
|
|
||||||
|
# handler for /
|
||||||
|
async def get__root(request: aiohttp.web.Request):
|
||||||
|
|
||||||
|
# Log request
|
||||||
|
now = datetime.now()
|
||||||
|
now = now.strftime("%d.%m.%Y-%H:%M:%S")
|
||||||
|
print(f'[{ now }] { request.remote } { request.method } { request.path_qs }')
|
||||||
|
|
||||||
|
# Page
|
||||||
|
# <template:get__root>
|
||||||
|
return aiohttp.web.FileResponse('index.html')
|
||||||
|
# </template:get__root>
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
|
||||||
|
# Args
|
||||||
|
parser = argparse.ArgumentParser(description='Process some integers.')
|
||||||
|
parser.add_argument('--port', type=int, default=7417, metavar='{1..65535}', choices=range(1, 65535), help='server port')
|
||||||
|
parser.add_argument('--password', type=str, default=None, help='password for remote control session')
|
||||||
|
parser.add_argument('--view_password', type=str, default=None, help='password for view only session (can only be set if --password is set)')
|
||||||
|
parser.add_argument('--fullscreen', action='store_true', default=False, help='enable multi-display screen capture')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Password post-process
|
||||||
|
if args.password is None:
|
||||||
|
|
||||||
|
# If no passwords set, enable no-password input+view mode
|
||||||
|
if args.view_password is None:
|
||||||
|
args.password = ''
|
||||||
|
|
||||||
|
# If only view password set, enable password-protected view mode
|
||||||
|
else:
|
||||||
|
args.view_password = args.view_password.strip()
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
# Enable password-protected input+view mode
|
||||||
|
args.password = args.password.strip()
|
||||||
|
|
||||||
|
# If view password is set, enable password-protected view mode
|
||||||
|
if args.view_password is not None:
|
||||||
|
args.view_password = args.view_password.strip()
|
||||||
|
|
||||||
|
# Check for match and fallback to input + view mode
|
||||||
|
if args.password == args.view_password:
|
||||||
|
args.view_password = None
|
||||||
|
|
||||||
|
# Set up server
|
||||||
|
app = aiohttp.web.Application()
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
app.router.add_get('/connect_input_ws', get__connect_input_ws)
|
||||||
|
app.router.add_get('/connect_view_ws', get__connect_view_ws)
|
||||||
|
app.router.add_get('/', get__root)
|
||||||
|
|
||||||
|
# Listen
|
||||||
|
aiohttp.web.run_app(app=app, port=args.port)
|
||||||
1249
src/index.html
Normal file
1249
src/index.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user