CTFd Whale提供的动态容器类型的题目是通过镜像名称创建service进而创建题目实例的,但是如果题目镜像并没有上传的dockerhub(比如只有题目的tar文件),或者题目更新了,这时候如果我们想要更新/创建题目就需要手动进入服务器上传镜像或者用tag进行区分,很是麻烦。为了解决这个问题,我们就给whale加点功能吧。
更新镜像 首先就是更新镜像的功能,我们直接在前端题目页面增加一个更新镜像的按钮:
在\CTFd\plugins\ctfd-whale\assets\update.html中修改以下内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 <div class="form-group"> <label for="value">Docker镜像<br> <small class="form-text text-muted"> 用来部署的docker镜像名称 </small> </label> <div style="display: flex;gap: 10px;"> <input type="text" class="form-control" name="docker_image" placeholder="输入镜像名称" id="docker_name_input" required value="{{ challenge.docker_image }}" style="flex: 1;"> <button class="btn btn-success btn-outlined" type="button" onclick="UpdateDockerImage()" style="flex: none;"> 更新镜像 </button> </div> </div>
然后在update.js里写以下函数(至于为什么用CTFd.fetch,之后再说)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 function UpdateDockerImage ( ){ var name = document .getElementById ("docker_name_input" ).value ; var url = "/plugins/ctfd-whale/admin/image-update?name=" + name; CTFd .fetch (url, { method : 'GET' , credentials : 'same-origin' , headers : { 'Accept' : 'application/json' , } }).then (function (response ) { if (response.status === 429 ) { return response.json (); } if (response.status === 403 ) { return response.json (); } return response.json (); }).then (function (response ) { if (response.success ) { var e = new Object ; e.title = "更新成功!" ; e.body = "镜像更新完成!" ; CTFd .ui .ezq .ezToast (e) } else { var e = new Object ; e.title = "更新失败!" ; e.body = response.message ; CTFd .ui .ezq .ezToast (e) } }); }
接下来在后端(\CTFd\plugins\ctfd-whale\init .py)中注册路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @page_blueprint.route("/admin/image-update" ) @admins_only def admin_image_update (): try : name = request.args.get('name' ) DockerUtils.client.api.pull(name) print ("[CTFd Whale] " + name + "镜像更新成功" ) return { 'success' : True , 'message' : '镜像更新完成' }, 200 except Exception as e: print ("[CTFd Whale] " + name + "镜像更新失败" ) print (e) return { 'success' : False , 'message' : '镜像更新出错:<br>' + str (e.__cause__) }, 200
这样更新镜像的功能就写完了。
上传镜像 接下来就是上传镜像的功能了,先写一个简单的上传页面。
在Whale的Templates目录下创建whale_upload.html,记得也在别的页面修改导航栏哦。(extend那些渲染标记省略了,对号入座就行)
1 2 3 4 5 6 7 8 9 10 11 12 <li class="nav-item"> <a class="nav-link" href="/plugins/ctfd-whale/admin/settings">🔗 设置</a> </li> <li class="nav-item"> <a class="nav-link" href="/plugins/ctfd-whale/admin/containers">🔗 实例</a> </li> <li class="nav-item"> <a class="nav-link active" href="#">上传</a> </li>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 <div> <div class="form-group" id="drop"> <p>您可以在下面上传镜像的tar文件,服务器端会尝试导入镜像。您应该只上传您信任的镜像!</p> <div class="form-group"> <div style="display: flex;gap: 10px;"> <div style="flex: 1;"> <label for="value">镜像name<br> <small class="form-text text-muted"> eg. fr000g/untrain1 </small> </label> <input type="text" class="form-control" name="docker_image_name" id="docker_image_name" placeholder="输入镜像name" id="docker_name_input" required="" value=""> </div> <div style="flex: 1;"> <label for="value">镜像tag<br> <small class="form-text text-muted"> eg. latest </small> </label> <input type="text" class="form-control" name="docker_image_tag" id="docker_image_tag" placeholder="输入镜像tag" id="docker_tag_input" required="" value="latest"> </div> </div> </div> <div class="drop-area" ondragover="event.preventDefault()" ondrop="handleDrop(event)" style="border: 2px dashed #ccc;padding: 20px;text-align: center;height: 200px;display: flex;align-items: center;justify-content: center;"> <div class="centered-content" style="display: flex;flex-direction: column;align-items: center;justify-content: center;height: 100%;"> <h2>将镜像文件拖拽至此处上传(请一次仅拖拽一个文件)</h2> </div> </div> </div> </div>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 <script> function handleDrop (event ) { event.preventDefault (); if (docker_image_name.value =="" ) { var e = new Object ; e.title = "警告" ; e.body = "未设置镜像name" ; e.button ="知道了" ; CTFd .ui .ezq .ezAlert (e) return ; } if (docker_image_tag.value =="" ) { var e = new Object ; e.title = "警告" ; e.body = "未设置镜像tag" ; e.button ="知道了" ; CTFd .ui .ezq .ezAlert (e) return ; } var files = event.dataTransfer .files ; var progressBarContainer = document .getElementById ("drop" ); var file = files[0 ]; var progressBar = createProgressBar (progressBarContainer); var fileNameElement = document .createElement ('div' ); fileNameElement.classList .add ('file-name' ); fileNameElement.textContent = file.name ; fileNameElement.style = "margin-bottom: 5px;" progressBarContainer.appendChild (fileNameElement); uploadFile (file, progressBar, fileNameElement); } function createProgressBar (progressBarContainer ) { var progressBar = document .createElement ('div' ); progressBar.classList .add ('progress-bar' ); progressBar.style = "width: 100%;background-color: #f5f5f5;border-radius: 4px;overflow: hidden;margin-bottom: 10px;margin-top: 5px;" var progress = document .createElement ('div' ); progress.classList .add ('progress' ); progress.style = "width: 0;height: 20px;background-color: #4caf50;transition: width 0.3s ease-in-out;" progressBar.appendChild (progress); progressBarContainer.appendChild (progressBar); return progressBar; } function updateProgress (progressBar, percent ) { progressBar.getElementsByClassName ('progress' )[0 ].style .width = percent + '%' ; } function uploadFile (file, progressBar, fileNameElement ) { var formData = new FormData (); formData.append ('image' , file); formData.append ("nonce" , init.csrfNonce ); $.ajax ({ url : '/plugins/ctfd-whale/admin/upload?name=' +encodeURIComponent (docker_image_name.value )+'&tag=' +encodeURIComponent (docker_image_tag.value ), type : 'POST' , headers : { "Accept" : "application/json; charset=utf-8" }, data : formData, processData : false , contentType : false , xhr : function ( ) { var xhr = new window .XMLHttpRequest (); xhr.upload .addEventListener ('progress' , function (event ) { if (event.lengthComputable ) { var percent = Math .round ((event.loaded / event.total ) * 100 ); progressBar.getElementsByClassName ('progress' )[0 ].style .width = percent + '%' ; } }, false ); return xhr; }, success : function (response ) { fileNameElement.remove (); progressBar.remove (); docker_image_name.value ="" ; var e = new Object ; e.title = "上传成功!" ; e.body = "镜像上传完成!" ; CTFd .ui .ezq .ezToast (e) }, error : function (xhr, status, error ) { fileNameElement.remove (); progressBar.remove (); var e = new Object ; e.title = "上传失败!" ; e.body = JSON .parse (xhr.responseText ).message ; e.button ="知道了" ; CTFd .ui .ezq .ezAlert (e) } }); } </script>
至于为什么css混一起写,emmm,懒了,应该提取出来的实际上。
那就有人问了,你咋不用CTFd.fetch了呢?这就涉及到一个坑爹的地方了:
CTFd.fetch表面上看起来可以用来Post发送文件,他也确实发送了,但是flask的后端接收不到任何文件,request.files直接是空的!
浏览器抓包发现,发送的数据包的content-type是application/json,而这个type是在代码里强行设置的,自定义无效,这就导致flask无法正确接受。然后用ajax吧,会直接403,这是由于flask的csrf防御措施需要一个token,源代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @app.before_request def csrf (): try : func = app.view_functions[request.endpoint] except KeyError: abort(404 ) if hasattr (func, "_bypass_csrf" ): return if request.headers.get("Authorization" ): return if not session.get("nonce" ): session["nonce" ] = generate_nonce() if request.method not in ("GET" , "HEAD" , "OPTIONS" , "TRACE" ): if request.content_type == "application/json" : if session["nonce" ] != request.headers.get("CSRF-Token" ): abort(403 ) if request.content_type != "application/json" : if session["nonce" ] != request.form.get("nonce" ): abort(403 )
所以说,如果是json那就写一个csrf-token的header,否则就在form里加上nonce名称的token,这个token可以在init.csrfNonce里拿到。
接下来就是后台的处理了,还是在init.py里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 @page_blueprint.route("/admin/upload" , methods=['GET' , 'POST' ] ) @admins_only def admin_upload_image (): if request.method == 'POST' : name = request.args.get("name" ) if not name: return { 'success' : False , 'message' : '缺少参数' }, 400 tag = request.args.get("tag" ) if not tag: return { 'success' : False , 'message' : '缺少参数' }, 400 if 'image' not in request.files: return { 'success' : False , 'message' : '镜像文件不存在' }, 500 file = request.files['image' ] if file.filename == '' : return { 'success' : False , 'message' : '镜像文件为空' }, 500 if file: try : filepath = os.path.join(app.config['UPLOAD_FOLDER' ], file.filename) file.save(filepath) print ("[CTFd Whale] 上传的镜像文件 " + name + ":" + tag + " 保存至:" + filepath) try : image_info = DockerUtils.client.images.get(name + ":" + tag) DockerUtils.client.api.remove_image(name + ":" + tag) except Exception as e: pass DockerUtils.client.api.import_image_from_file(filepath, repository=name, tag=tag) print ("[CTFd Whale] " + name + ":" + tag + "导入完成" ) os.remove(filepath) return { 'success' : True , 'message' : '镜像上传完成' }, 200 except Exception as e: print (e) return { 'success' : False , 'message' : '镜像加载失败<br>' + str (e) }, 500 return render_template("whale_upload.html" )
至于为什么要先获取镜像再删除,是因为docker-py如果不指定name和tag是不会自动识别的,会成为none;另外如果加载时有同名同tag镜像,原先的镜像的name和tag都会被变成none,会产生镜像垃圾。 效果如下 好的,完活,下机!
PS:我们Scr1w战队二次开发的CTFd整合版地址:https://github.com/dlut-sss/CTFD-Public