[go: up one dir, main page]

Skip to content

Commit

Permalink
Get edit cfg files over ssh working
Browse files Browse the repository at this point in the history
* One of the last big hurdles in getting part 2.5 wrapped up.
  - Opens up cfg file editing to local installs owned by other users without
    having to route stuff through the ansible connector script.
  - More details in Todos.md.
* Made `find_cfg_paths()` work for remote installs too.
* Created new `read_file_over_ssh()` function for fetching cfg file contents for
  remote installs.
  - Uses paramiko's sftp open to read.
* Created new `write_file_over_ssh()` to update cfg files with users desired
  changes.
  - Uses paramiko's sftp open to write changes.
* Made download and save buttons work for edit page for remote installs.
  - Used Flask's `send_file()` to send the str contents as a file like object.
  • Loading branch information
BlueSquare23 committed Nov 11, 2024
1 parent 6be8088 commit a790d95
Show file tree
Hide file tree
Showing 3 changed files with 177 additions and 93 deletions.
2 changes: 1 addition & 1 deletion Todos.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
- I might keep that for local installs, but for remote just remove files.
- Maybe I should add a toggle / config setting for "cleanup system user
on delete" or something like that.
- [ ] Figure out update config files over SSH.
- [x] Figure out update config files over SSH.
- I was going to put this off for a future release, but then there's no
good way to do edit cfg files for game servers installed as other
system users.
Expand Down
178 changes: 122 additions & 56 deletions app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,28 +459,72 @@ def get_running_installs():
return thread_names


# Returns list of any game server cfg listed in accepted_cfgs.json under the
# search_path.
def find_cfg_paths(search_path):
def find_cfg_paths(server):
"""
Finds a list of all valid cfg files for a given game server.
Args:
server (GameServer): Game server to find cfg files for.
Returns:
cfg_paths (list): List of found valid config files.
"""
cfg_paths = []

# Try except in case problem with json files.
try:
cfg_whitelist = open("json/accepted_cfgs.json", "r")
json_data = json.load(cfg_whitelist)
cfg_whitelist.close()
except:
return "failed"
return cfg_paths

cfg_paths = []
valid_gs_cfgs = json_data["accepted_cfgs"]

# Find all cfgs under search_path using os.walk.
for root, dirs, files in os.walk(search_path):
# Ignore default cfgs.
if "config-default" in root:
continue
for file in files:
if file in valid_gs_cfgs:
cfg_paths.append(os.path.join(root, file))
if server.install_type == 'local':
# Find all cfgs under install_path using os.walk.
for root, dirs, files in os.walk(server.install_path):
# Ignore default cfgs.
if "config-default" in root:
continue
for file in files:
if file in valid_gs_cfgs:
cfg_paths.append(os.path.join(root, file))

if server.install_type == 'remote':
proc_info = ProcInfoVessel()
keyfile = get_ssh_key_file(server.username, server.install_host)
wanted = []
for cfg in valid_gs_cfgs:
wanted += ['-name', cfg, '-o']
cmd = ['/usr/bin/find', server.install_path, '-name', 'config-default', '-prune', '-type', 'f'] + wanted[:-1]

success = run_cmd_ssh(
cmd,
server.install_host,
server.username,
keyfile,
proc_info
)

# If the ssh connection itself fails return False.
if not success:
current_app.logger.info(proc_info)
flash("Problem connecting to remote host!", category="error")
return cfg_paths

if proc_info.exit_status > 0:
current_app.logger.info(proc_info)
flash("Problem running find cfg cmd. Check logs for more details.", category="error")
return cfg_paths

for item in proc_info.stdout:
item = item.strip()
current_app.logger.info(item)

# Check str coming back is valid cfg name str.
if os.path.basename(item) in valid_gs_cfgs:
cfg_paths.append(item)

return cfg_paths

Expand Down Expand Up @@ -1080,7 +1124,7 @@ def gen_ssh_rule(pub_key):
"""
return f'{pub_key} command="/path/to/ssh_connector.sh"'

# ...
# TODO: Finish this.


def run_cmd_ssh(cmd, hostname, username, key_filename, proc_info=ProcInfoVessel(), app_context=False, timeout=5.0):
Expand All @@ -1100,6 +1144,7 @@ def run_cmd_ssh(cmd, hostname, username, key_filename, proc_info=ProcInfoVessel(
Returns:
bool: True if command runs successfully, False otherwise.
"""
# TODO: Make not clear if config/settings option for keep output is set.
proc_info.stdout.clear()
proc_info.stderr.clear()

Expand Down Expand Up @@ -1137,9 +1182,6 @@ def run_cmd_ssh(cmd, hostname, username, key_filename, proc_info=ProcInfoVessel(
if timeout:
channel.settimeout(timeout)

stdout_buffer = ""
stderr_buffer = ""

# RANT: Because of course, why would the most popular ssh module for
# Python make it easy to read output by newlines? Paramiko's opinion is
# "hey developer you can go fuck yourself and parse the raw byte chucks
Expand Down Expand Up @@ -1186,45 +1228,6 @@ def run_cmd_ssh(cmd, hostname, username, key_filename, proc_info=ProcInfoVessel(
# Keep CPU from burning.
time.sleep(0.1)

## WORKS, CONSOLE BROKEN, BUT CLOSE TO WORKING...
# stdout_buffer = ""
# stderr_buffer = ""
#
## while True:
# while not channel.exit_status_ready():
# if channel.recv_ready():
# stdout_buffer += channel.recv(1024).decode('utf-8')
# stdout_lines = stdout_buffer.splitlines(keepends=True)
#
# for line in stdout_lines:
# line = line.replace('\r', '')
# if line.endswith('\n'):
# if line not in proc_info.stdout:
# proc_info.stdout.append(line)
# log_msg = log_wrap('stdout', line.strip())
# current_app.logger.debug(log_msg)
# else:
# stdout_buffer = line # Save incomplete line in buffer.
#
# if channel.recv_stderr_ready():
# stderr_buffer += channel.recv_stderr(1024).decode('utf-8')
# stderr_lines = stderr_buffer.splitlines(keepends=True)
#
# for line in stderr_lines:
# line = line.replace('\r', '')
# if line.endswith('\n'):
# if line not in proc_info.stderr:
# proc_info.stderr.append(line)
# log_msg = log_wrap('stderr', line.strip())
# current_app.logger.debug(log_msg)
# else:
# stderr_buffer = line # Save incomplete line in buffer.
#
## if channel.exit_status_ready():
## break
#
# time.sleep(0.1)

# Wait for the command to finish and get the exit status.
proc_info.exit_status = channel.recv_exit_status()
proc_info.process_lock = False
Expand All @@ -1249,12 +1252,75 @@ def run_cmd_ssh(cmd, hostname, username, key_filename, proc_info=ProcInfoVessel(
return ret_status


def read_file_over_ssh(server, file_path):
"""
Reads a file from a remote server over SSH and returns its content. Used
for updating config files for remote installs. However, its been built as a
general purpose read file over ssh using paramiko sftp.
Args:
server (GameServer): Server to get file for.
file_path: The path of the file to read on the remote machine.
Returns:
str: Returns the contents of the file as a string.
"""
current_app.logger.info(log_wrap('file_path', file_path))
pub_key_file = get_ssh_key_file(server.username, server.install_host)

try:
# Open ssh client conn.
with paramiko.SSHClient() as ssh:
# Automatically add the host key.
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(server.install_host, username=server.username, key_filename=pub_key_file, timeout=3)

# Open sftp session.
with ssh.open_sftp() as sftp:
# Open file over sftp.
with sftp.open(file_path, "r") as file:
content = file.read()

return content.decode()

except Exception as e:
current_app.logger.debug(e)
return None


def write_file_over_ssh(server, file_path, content):
"""
Writes a string to a file on a remote server over SSH. Similarly to
read_file_over_ssh(), this function is used to update cfg files for remote
game servers. However, it is written as a general purpose write over ssh
using paramiko sftp.
Parameters:
server (GameServer): Game Server to write the cfg file changes for.
file_path (str): The path of the file to write on the remote server.
content (str): The string content to write to the file.
Returns:
Bool: True if the write was successful, False otherwise.
"""
current_app.logger.info(log_wrap('file_path', file_path))
pub_key_file = get_ssh_key_file(server.username, server.install_host)

try:
with paramiko.SSHClient() as ssh:
# Automatically add the host key.
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(server.install_host, username=server.username, key_filename=pub_key_file, timeout=3)

with ssh.open_sftp() as sftp:
with sftp.open(file_path, "w") as file:
file.write(content)

return True

except Exception as e:
current_app.logger.debug(e)
return False



Expand Down
90 changes: 54 additions & 36 deletions app/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import io
import re
import sys
import json
Expand All @@ -19,6 +20,7 @@
url_for,
redirect,
Response,
send_file,
send_from_directory,
jsonify,
current_app
Expand Down Expand Up @@ -108,7 +110,7 @@ def controls():
config.read("main.conf")
text_color = config["aesthetic"]["text_color"]
terminal_height = config["aesthetic"]["terminal_height"]
cfg_editor = config["settings"]["cfg_editor"]
cfg_editor = config["settings"].getboolean("cfg_editor")
send_cmd = config["settings"].getboolean("send_cmd")
clear_output_on_reload = config["settings"].getboolean("clear_output_on_reload")

Expand Down Expand Up @@ -143,10 +145,10 @@ def controls():
return redirect(url_for("views.home"))

# If config editor is disabled in the main.conf.
if cfg_editor == "no":
if not cfg_editor:
cfg_paths = []
else:
cfg_paths = find_cfg_paths(server.install_path)
cfg_paths = find_cfg_paths(server)
if cfg_paths == "failed":
flash("Error reading accepted_cfgs.json!", category="error")
cfg_paths = []
Expand Down Expand Up @@ -1075,11 +1077,6 @@ def edit():
flash("Invalid game server name!", category="error")
return redirect(url_for("views.home"))

# Checks that install dir exists.
if not os.path.isdir(server.install_path):
flash("No game server installation directory found!", category="error")
return redirect(url_for("views.home"))

# Try to pull script's basename from supplied cfg_path.
try:
cfg_file = os.path.basename(cfg_path)
Expand All @@ -1092,38 +1089,59 @@ def edit():
flash("Invalid config file name!", category="error")
return redirect(url_for("views.home"))

# Check that file exists before allowing writes to it. Aka don't allow
# arbitrary file creation. Even though the above should block creating
# files with arbitrary names, we still don't want to allow arbitrary file
# creation anywhere on the file system the app has write perms to.
if not os.path.isfile(cfg_path):
flash("No such file!", category="error")
return redirect(url_for("views.home"))
if should_use_ssh(server):
# If new contents are supplied via POST, write them to file over ssh.
if new_file_contents:
written = write_file_over_ssh(server, cfg_path, new_file_contents.replace("\r", ""))
if written:
flash("Cfg file updated!", category="success")
else:
flash("Error writing to cfg file!", category="error")
return redirect(url_for("views.home"))

# Read in file contents over ssh.
file_contents = read_file_over_ssh(server, cfg_path)
if file_contents == None:
flash("Problem reading cfg file!", category="error")
return redirect(url_for("views.home"))

# If is download request.
if download == "yes":
file_like_thingy = io.BytesIO(file_contents.encode('utf-8'))
return send_file(file_like_thingy, as_attachment=True, download_name=cfg_file, mimetype="text/plain")
else:
# Check that file exists before allowing writes to it. Aka don't allow
# arbitrary file creation. Even though the above should block creating
# files with arbitrary names, we still don't want to allow arbitrary file
# creation anywhere on the file system the app has write perms to.
if not os.path.isfile(cfg_path):
flash("No such file!", category="error")
return redirect(url_for("views.home"))

# If new_file_contents supplied in post request, write the new file
# contents to the cfg file.
if new_file_contents:
# If new_file_contents supplied in post request, write the new file
# contents to the cfg file.
if new_file_contents:
try:
with open(cfg_path, "w") as f:
f.write(new_file_contents.replace("\r", ""))
flash("Cfg file updated!", category="success")
except:
flash("Error writing to cfg file!", category="error")

# Read in file contents from cfg file.
file_contents = ""
# Try except in case problem with file.
try:
with open(cfg_path, "w") as f:
f.write(new_file_contents.replace("\r", ""))
flash("Config Updated!", category="success")
with open(cfg_path) as f:
file_contents = f.read()
except:
flash("Error writing to config!", category="error")

# Read in file contents from cfg file.
file_contents = ""
# Try except incase problem with file.
try:
with open(cfg_path) as f:
file_contents = f.read()
except:
flash("Error reading config!", category="error")
return redirect(url_for("views.home"))
flash("Error reading config!", category="error")
return redirect(url_for("views.home"))

# If is download request.
if download == "yes":
basedir, basename = os.path.split(cfg_path)
return send_from_directory(basedir, basename, as_attachment=True)
# If is download request.
if download == "yes":
basedir, basename = os.path.split(cfg_path)
return send_from_directory(basedir, basename, as_attachment=True)

return render_template(
"edit.html",
Expand Down

0 comments on commit a790d95

Please sign in to comment.