Update 2025-12-19 19:18:16
This commit is contained in:
Binary file not shown.
Binary file not shown.
32
app.py
32
app.py
@@ -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("🤖 텔레그램 봇 폴링 스레드 생성됨 (중복 방지 플래그 적용)")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
Binary file not shown.
@@ -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 방식)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 = (
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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')); // 로컬 스토리지 업데이트 및 카운트 갱신 트리거
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 %}
|
||||
10
config.py
10
config.py
@@ -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 안정)
|
||||
|
||||
7459
data/logs/app.log
7459
data/logs/app.log
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
Reference in New Issue
Block a user