Update 2025-12-19 19:18:16

This commit is contained in:
unknown
2025-12-19 19:18:16 +09:00
parent b18412ecb2
commit b37c43ab86
19 changed files with 7629 additions and 89 deletions

Binary file not shown.

32
app.py
View File

@@ -46,6 +46,13 @@ setup_logging(app)
csrf = CSRFProtect()
csrf.init_app(app)
# ─────────────────────────────────────────────────────────────
# ProxyFix: Nginx/NPM 등 리버스 프록시 뒤에서 실행 시 헤더 신뢰
# (HTTPS 인식, 올바른 IP/Scheme 파악으로 CSRF/세션 문제 해결)
# ─────────────────────────────────────────────────────────────
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
@app.context_processor
def inject_csrf():
@@ -121,31 +128,38 @@ register_routes(app, socketio)
# ─────────────────────────────────────────────────────────────
# 텔레그램 봇 폴링 서비스 (중복 실행 방지 포함)
# ─────────────────────────────────────────────────────────────
_bot_polling_started = False # 전역 플래그로 중복 방지
_bot_socket_lock = None
def start_telegram_bot_polling() -> None:
"""텔레그램 봇 폴링을 백그라운드 스레드로 시작 (한 번만 실행)"""
"""텔레그램 봇 폴링을 백그라운드 스레드로 시작 (TCP 소켓 락으로 중복 방지)"""
import threading
import socket
global _bot_polling_started
global _bot_socket_lock
if _bot_polling_started:
app.logger.warning("🤖 텔레그램 봇 폴링은 이미 시작됨 - 중복 요청 무시")
if _bot_socket_lock:
return
_bot_polling_started = True
app.logger.info("🔒 봇 중복 실행 방지 락(TCP:50000) 획득 시도...")
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 50000))
s.listen(1)
_bot_socket_lock = s
app.logger.info("🔒 락 획득 성공! 봇 폴링 스레드를 시작합니다.")
except OSError:
app.logger.warning("⛔ 락 획득 실패: 이미 다른 프로세스(또는 좀비 프로세스)가 포트 50000을 점유 중입니다. 봇 폴링을 건너뜁니다.")
return
def _runner():
try:
# telegram_bot_service.run_polling(app) 호출
telegram_run_polling(app)
except Exception as e:
app.logger.error("텔레그램 봇 폴링 서비스 오류: %s", e)
polling_thread = threading.Thread(target=_runner, daemon=True)
polling_thread.start()
app.logger.info("🤖 텔레그램 봇 폴링 스레드 생성됨 (중복 방지 플래그 적용)")
# ─────────────────────────────────────────────────────────────

View File

@@ -113,14 +113,24 @@ class User(db.Model, UserMixin):
q = (email or "").strip().lower()
if not q:
return None
return User.query.filter_by(email=q).first()
try:
return User.query.filter_by(email=q).first()
except Exception as e:
logging.error(f"User find_by_email error: {e}")
db.session.rollback()
return None
@staticmethod
def find_by_username(username: Optional[str]) -> Optional["User"]:
q = (username or "").strip()
if not q:
return None
return User.query.filter_by(username=q).first()
try:
return User.query.filter_by(username=q).first()
except Exception as e:
logging.error(f"User find_by_username error: {e}")
db.session.rollback()
return None
# Flask-Login user_loader (SQLAlchemy 2.0 방식)

View File

@@ -5,7 +5,7 @@ from .auth import register_auth_routes
from .admin import register_admin_routes
from .main import register_main_routes
from .xml import register_xml_routes
from .utilities import register_util_routes
from .utilities import register_util_routes, utils_bp
from .file_view import register_file_view
from .jobs import register_jobs_routes
from .idrac_routes import register_idrac_routes
@@ -21,7 +21,7 @@ def register_routes(app: Flask, socketio=None) -> None:
register_admin_routes(app)
register_main_routes(app, socketio)
register_xml_routes(app)
register_util_routes(app)
app.register_blueprint(utils_bp, url_prefix="/utils")
register_file_view(app)
register_jobs_routes(app)
register_idrac_routes(app)

View File

@@ -270,8 +270,15 @@ def register():
approval_token=approval_token
)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
try:
db.session.add(user)
db.session.commit()
except Exception as e:
db.session.rollback()
current_app.logger.error("REGISTER: DB commit failed: %s", e)
flash("회원가입 처리 중 오류가 발생했습니다. (DB Error)", "danger")
return render_template("register.html", form=form)
# 텔레그램 알림 (인라인 버튼 포함)
message = (

View File

@@ -207,13 +207,13 @@ def delete_file(filename: str):
if file_path.exists():
try:
file_path.unlink()
flash(f"{filename} 삭제됨.")
flash(f"'{filename}' 파일이 삭제되었습니다.", "success")
logging.info(f"파일 삭제됨: {filename}")
except Exception as e:
logging.error(f"파일 삭제 오류: {e}")
flash("파일 삭제 중 오류가 발생했습니다.", "danger")
else:
flash("파일이 존재하지 않습니다.")
flash("파일이 존재하지 않습니다.", "warning")
return redirect(url_for("main.index"))

View File

@@ -301,68 +301,72 @@ def scan_network():
지정된 IP 범위(Start ~ End)에 대해 Ping 테스트를 수행하고
응답이 있는 IP 목록을 반환합니다.
"""
import ipaddress
import platform
import concurrent.futures
data = request.get_json()
start_ip_str = data.get('start_ip')
end_ip_str = data.get('end_ip')
if not start_ip_str or not end_ip_str:
return jsonify({"success": False, "error": "시작 IP와 종료 IP를 모두 입력해주세요."}), 400
try:
start_ip = ipaddress.IPv4Address(start_ip_str)
end_ip = ipaddress.IPv4Address(end_ip_str)
if start_ip > end_ip:
return jsonify({"success": False, "error": "시작 IP가 종료 IP보다 큽니다."}), 400
# IP 개수 제한 (너무 많은 스캔 방지, 예: C클래스 2개 분량 512개)
if int(end_ip) - int(start_ip) > 512:
return jsonify({"success": False, "error": "스캔 범위가 너무 넓습니다. (최대 512개)"}), 400
import ipaddress
import platform
import concurrent.futures
except ValueError:
return jsonify({"success": False, "error": "유효하지 않은 IP 주소 형식입니다."}), 400
data = request.get_json(force=True, silent=True) or {}
start_ip_str = data.get('start_ip')
end_ip_str = data.get('end_ip')
if not start_ip_str or not end_ip_str:
return jsonify({"success": False, "error": "시작 IP와 종료 IP를 모두 입력해주세요."}), 400
# Ping 함수 정의
def ping_ip(ip_obj):
ip = str(ip_obj)
param = '-n' if platform.system().lower() == 'windows' else '-c'
timeout_param = '-w' if platform.system().lower() == 'windows' else '-W'
# Windows: -w 200 (ms), Linux: -W 1 (s)
timeout_val = '200' if platform.system().lower() == 'windows' else '1'
command = ['ping', param, '1', timeout_param, timeout_val, ip]
try:
# shell=False로 보안 강화, stdout/stderr 무시
res = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return ip if res.returncode == 0 else None
except Exception:
return None
start_ip = ipaddress.IPv4Address(start_ip_str)
end_ip = ipaddress.IPv4Address(end_ip_str)
if start_ip > end_ip:
return jsonify({"success": False, "error": "시작 IP가 종료 IP보다 큽니다."}), 400
# IP 개수 제한 (너무 많은 스캔 방지, 예: C클래스 2개 분량 512개)
if int(end_ip) - int(start_ip) > 512:
return jsonify({"success": False, "error": "스캔 범위가 너무 넓습니다. (최대 512개)"}), 400
active_ips = []
# IP 리스트 생성
# ipaddress 모듈을 사용하여 범위 내 IP 생성
target_ips = []
temp_ip = start_ip
while temp_ip <= end_ip:
target_ips.append(temp_ip)
temp_ip += 1
except ValueError:
return jsonify({"success": False, "error": "유효하지 않은 IP 주소 형식입니다."}), 400
# 병렬 처리 (최대 50 쓰레드)
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
results = executor.map(ping_ip, target_ips)
# 결과 수집 (None 제외)
active_ips = [ip for ip in results if ip is not None]
# Ping 함수 정의
def ping_ip(ip_obj):
ip = str(ip_obj)
param = '-n' if platform.system().lower() == 'windows' else '-c'
timeout_param = '-w' if platform.system().lower() == 'windows' else '-W'
# Windows: -w 200 (ms), Linux: -W 1 (s)
timeout_val = '200' if platform.system().lower() == 'windows' else '1'
command = ['ping', param, '1', timeout_param, timeout_val, ip]
try:
# shell=False로 보안 강화, stdout/stderr 무시
res = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return ip if res.returncode == 0 else None
except Exception:
return None
return jsonify({
"success": True,
"active_ips": active_ips,
"count": len(active_ips),
"message": f"스캔 완료: {len(active_ips)}개의 활성 IP 발견"
})
active_ips = []
# IP 리스트 생성
target_ips = []
temp_ip = start_ip
while temp_ip <= end_ip:
target_ips.append(temp_ip)
temp_ip += 1
# 병렬 처리 (최대 50 쓰레드)
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
results = executor.map(ping_ip, target_ips)
# 결과 수집 (None 제외)
active_ips = [ip for ip in results if ip is not None]
return jsonify({
"success": True,
"active_ips": active_ips,
"count": len(active_ips),
"message": f"스캔 완료: {len(active_ips)}개의 활성 IP 발견"
})
except Exception as e:
logging.error(f"Scan network fatal error: {e}")
return jsonify({"success": False, "error": f"서버 내부 오류: {str(e)}"}), 500

View File

@@ -152,7 +152,7 @@ document.addEventListener('DOMContentLoaded', () => {
await postFormAndHandle(macForm.action);
location.reload();
} catch (err) {
alert('MAC 이동 중 오류: ' + (err?.message || err));
alert('MAC 파일 이동 중 오류가 발생했습니다: ' + (err?.message || err));
btn.disabled = false;
btn.innerHTML = originalHtml;
}
@@ -175,7 +175,7 @@ document.addEventListener('DOMContentLoaded', () => {
await postFormAndHandle(guidForm.action);
location.reload();
} catch (err) {
alert('GUID 이동 중 오류: ' + (err?.message || err));
alert('GUID 파일 이동 중 오류가 발생했습니다: ' + (err?.message || err));
btn.disabled = false;
btn.innerHTML = originalHtml;
}
@@ -200,7 +200,7 @@ document.addEventListener('DOMContentLoaded', () => {
const btnScan = document.getElementById('btnStartScan');
if (btnScan) {
btnScan.addEventListener('click', async () => {
const startIp = '10.10.0.1';
const startIp = '10.10.0.2';
const endIp = '10.10.0.255';
const ipsTextarea = document.getElementById('ips');
const progressBar = document.getElementById('progressBar');
@@ -218,7 +218,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
progressBar.style.width = '100%';
progressBar.classList.add('progress-bar-striped', 'progress-bar-animated');
progressBar.textContent = '네트워크 스캔 중... (10.10.0.1 ~ 255)';
progressBar.textContent = 'IP 스캔 중...';
}
try {
@@ -231,6 +231,23 @@ document.addEventListener('DOMContentLoaded', () => {
body: JSON.stringify({ start_ip: startIp, end_ip: endIp })
});
// 1. 세션 만료로 인한 리다이렉트 감지
if (res.redirected) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.reload();
return;
}
// 2. JSON 응답인지 확인
const contentType = res.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const text = await res.text();
if (text.includes("CSRF")) {
throw new Error("보안 토큰(CSRF)이 만료되었습니다. 페이지를 새로고침해주세요.");
}
throw new Error(`서버 응답 오류 (HTTP ${res.status}): ${text.substring(0, 100)}...`);
}
const data = await res.json();
if (data.success) {
@@ -248,7 +265,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
} catch (err) {
console.error(err);
alert('오류 발생: ' + (err.message || err));
alert('오류 발생했습니다: ' + (err.message || err));
} finally {
// 상태 복구
btnScan.disabled = false;
@@ -264,4 +281,18 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// ─────────────────────────────────────────────────────────────
// IP 입력 지우기 버튼
// ─────────────────────────────────────────────────────────────
const btnClear = document.getElementById('btnClearIps');
if (btnClear) {
btnClear.addEventListener('click', () => {
const ipsTextarea = document.getElementById('ips');
if (ipsTextarea) {
ipsTextarea.value = '';
ipsTextarea.dispatchEvent(new Event('input')); // 로컬 스토리지 업데이트 및 카운트 갱신 트리거
}
});
}
});

View File

@@ -401,7 +401,7 @@ document.addEventListener('DOMContentLoaded', async () => {
if (monitoringOn) await fetchJobs(false);
}
} catch (e) {
alert('IP 목록 불러오기 실패: ' + e.message);
alert('IP 목록 불러오는 중 오류가 발생했습니다: ' + e.message);
}
});
$btnApply.addEventListener('click', () => {

View File

@@ -70,9 +70,13 @@
<span class="badge bg-secondary ms-1" id="ipLineCount">0</span>
</span>
<div class="d-flex align-items-center gap-1">
<button type="button" class="btn btn-sm btn-outline-primary" id="btnStartScan"
title="10.10.0.1 ~ 255 자동 스캔">
<i class="bi bi-search me-1"></i>IP 자동 스캔 (10.10.0.x)
<button type="button" class="btn btn-sm btn-outline-secondary px-2 py-1" id="btnClearIps"
title="입력 내용 지우기" style="font-size: 0.75rem;">
<i class="bi bi-trash me-1"></i>지우기
</button>
<button type="button" class="btn btn-sm btn-outline-primary px-2 py-1" id="btnStartScan"
title="10.10.0.1 ~ 255 자동 스캔" style="font-size: 0.75rem;">
<i class="bi bi-search me-1"></i>IP 스캔
</button>
</div>
</label>
@@ -220,7 +224,8 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="input-group input-group-sm">
<input type="text" class="form-control border-success-subtle form-control-sm"
name="backup_prefix" placeholder="Prefix" style="font-size: 0.75rem; padding: 0.2rem 0.5rem;">
name="backup_prefix" placeholder="ex)PO-20251117-0015_20251223_판교_R6615(TY1A)"
style="font-size: 0.75rem; padding: 0.2rem 0.5rem;">
<button class="btn btn-success btn-sm px-2" type="submit">
<i class="bi bi-save" style="font-size: 0.75rem;"></i>
</button>
@@ -543,8 +548,8 @@
});
</script>
<script src="{{ url_for('static', filename='js/index.js') }}"></script>
<script src="{{ url_for('static', filename='js/index.js') }}?v={{ range(1, 100000) | random }}"></script>
<!-- 외부 script.js 파일 (IP 폼 처리 로직 포함) -->
<script src="{{ url_for('static', filename='script.js') }}"></script>
<script src="{{ url_for('static', filename='script.js') }}?v={{ range(1, 100000) | random }}"></script>
{% endblock %}

View File

@@ -47,6 +47,15 @@ class Config:
# ── DB (환경변수 DATABASE_URL 있으면 그 값을 우선 사용)
sqlite_path = (INSTANCE_DIR / "site.db").as_posix()
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", f"sqlite:///{sqlite_path}")
# DB 연결 안정성 옵션 (SQLite 락/쓰레드 문제 완화)
SQLALCHEMY_ENGINE_OPTIONS = {
"pool_pre_ping": True,
"pool_recycle": 280,
}
if SQLALCHEMY_DATABASE_URI.startswith("sqlite"):
SQLALCHEMY_ENGINE_OPTIONS["connect_args"] = {"check_same_thread": False}
SQLALCHEMY_TRACK_MODIFICATIONS = False
# ── Telegram (미설정 시 기능 비활성처럼 동작)
@@ -78,6 +87,7 @@ class Config:
# ── 세션
PERMANENT_SESSION_LIFETIME = timedelta(minutes=int(os.getenv("SESSION_MINUTES", 30)))
SESSION_PERMANENT = True # 브라우저 닫아도 세션 유지 (타임아웃까지)
# ── SocketIO
# threading / eventlet / gevent 중 선택. 기본은 threading (Windows 안정)

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,7 @@ async def handle_approval_callback(update: Update, context: ContextTypes.DEFAULT
with flask_app.app_context():
# 토큰으로 사용자 찾기
user = User.query.filter_by(approval_token=token).first()
if not user:
await query.edit_message_text(
text="❌ 유효하지 않은 승인 요청입니다.\n(이미 처리되었거나 만료된 요청)"
@@ -168,6 +168,6 @@ def run_polling(flask_app: Flask) -> None:
try:
# v20 스타일: run_polling 은 동기 함수이고, 내부에서 이벤트 루프를 직접 관리함
application.run_polling(drop_pending_updates=True)
application.run_polling(drop_pending_updates=True, stop_signals=[])
except Exception as e:
logger.exception("Error in bot polling: %s", e)